From e6df30f079dd09273f402fb0a067d1d60ad16bbf Mon Sep 17 00:00:00 2001 From: Chris Branch Date: Tue, 13 Oct 2026 09:47:15 +0100 Subject: [PATCH 1/3] foundations: add Display derive for trivial struct/enum Display A simple way to quickly implement Display implementations on structs and enums, inspired by `thiserror` but not specifically for errors. Although, errors are a good reason for this (`std::error::Error`'s trait bounds require `Display`), so we also add a convenience derive named `Error` that adds the `impl Error for T` automatically. This is enabled by setting the "derive" feature. --- foundations-macros/src/display.rs | 491 ++++++++++++++++++++++++++++++ foundations-macros/src/lib.rs | 65 ++++ foundations/Cargo.toml | 3 + foundations/src/derive.rs | 43 +++ foundations/src/lib.rs | 3 + 5 files changed, 605 insertions(+) create mode 100644 foundations-macros/src/display.rs create mode 100644 foundations/src/derive.rs diff --git a/foundations-macros/src/display.rs b/foundations-macros/src/display.rs new file mode 100644 index 00000000..57301c03 --- /dev/null +++ b/foundations-macros/src/display.rs @@ -0,0 +1,491 @@ +use proc_macro2::{Span, TokenStream}; +use quote::{format_ident, quote}; +use syn::{Attribute, Data, DeriveInput, Fields, LitStr}; + +#[derive(Clone, Copy)] +pub(crate) enum DisplayOptions { + None, + ImplError, +} + +pub(crate) fn expand( + input: proc_macro::TokenStream, + options: DisplayOptions, +) -> proc_macro::TokenStream { + let input = syn::parse_macro_input!(input as DeriveInput); + + expand_from_parsed(input, options) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +fn expand_from_parsed(input: DeriveInput, options: DisplayOptions) -> syn::Result { + let name = &input.ident; + let formatter = syn::Ident::new("__foundations_formatter", Span::mixed_site()); + + let body = match &input.data { + Data::Struct(data) => { + let (pattern, output) = format_fields(&input.attrs, &data.fields)?; + quote! { + let Self #pattern = self; + #output + } + } + Data::Enum(data) => { + let arms = expand_enum(&input.attrs, data)?; + quote! { match self { #(#arms),* } } + } + Data::Union(_) => { + return Err(syn::Error::new_spanned( + name, + "Display cannot be derived for unions", + )); + } + }; + + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + + let display_impl = quote! { + #[automatically_derived] + impl #impl_generics ::std::fmt::Display for #name #type_generics #where_clause { + fn fmt(&self, #formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + #body + } + } + }; + + if matches!(options, DisplayOptions::ImplError) { + Ok(quote! { + #display_impl + #[automatically_derived] + impl #impl_generics ::std::error::Error for #name #type_generics #where_clause {} + }) + } else { + Ok(display_impl) + } +} + +fn expand_enum(attrs: &[Attribute], data: &syn::DataEnum) -> syn::Result> { + if let Some(attribute) = find_display_attr(attrs) { + return Err(syn::Error::new_spanned( + attribute, + "put #[display(\"...\")] on each variant", + )); + } + + data.variants + .iter() + .map(|variant| { + let variant_name = &variant.ident; + let (pattern, output) = format_fields(&variant.attrs, &variant.fields)?; + Ok(quote! { Self::#variant_name #pattern => { #output } }) + }) + .collect::>>() +} + +fn format_fields( + attributes: &[Attribute], + fields: &Fields, +) -> syn::Result<(TokenStream, TokenStream)> { + let formatter = syn::Ident::new("__foundations_formatter", Span::mixed_site()); + let mut display_attributes = attributes.iter().filter(is_display_attr); + + let attribute = display_attributes.next().ok_or_else(|| { + syn::Error::new_spanned( + fields, + "expected #[display(\"...\")] with one format string", + ) + })?; + + let message = attribute.parse_args::()?; + + if let Some(duplicate) = display_attributes.next() { + return Err(syn::Error::new_spanned( + duplicate, + "duplicate #[display] attribute", + )); + } + + for field in fields { + if let Some(attribute) = find_display_attr(&field.attrs) { + return Err(syn::Error::new_spanned( + attribute, + "field display attributes are not supported", + )); + } + } + + let (pattern, bindings, message) = match fields { + Fields::Named(fields) => { + let names = fields + .named + .iter() + .map(|field| field.ident.as_ref().unwrap()) + .collect::>(); + ( + quote! { { #(#names),* } }, + quote! { #(let _ = #names;)* }, + message, + ) + } + Fields::Unnamed(fields) => { + let names = (0..fields.unnamed.len()) + .map(|index| format_ident!("__foundations_field_{index}")) + .collect::>(); + let message = rewrite_format_message_for_tuples(&message, names.len())?; + ( + quote! { (#(#names),*) }, + quote! { #(let _ = #names;)* }, + message, + ) + } + Fields::Unit => (quote! {}, quote! {}, message), + }; + + Ok(( + pattern, + quote! { + #bindings + ::std::write!(#formatter, #message) + }, + )) +} + +fn is_display_attr(attr: &&Attribute) -> bool { + attr.path().is_ident("display") +} + +fn find_display_attr(attrs: &[Attribute]) -> Option<&Attribute> { + attrs.iter().find(is_display_attr) +} + +fn rewrite_format_message_for_tuples(message: &LitStr, field_count: usize) -> syn::Result { + #[derive(Clone, PartialEq, Eq)] + enum State { + Text, + OpenBrace, + Argument(String), + FormatSpec, + } + #[derive(Clone, Copy)] + enum ArgType { + Implicit, + Explicit, + } + + let text = message.value(); + let mut output = String::with_capacity(text.len()); + let mut state = State::Text; + let mut implicit_index = 0; + let mut explicit_indexing = None; + + let mut resolve_argument = |argument: &str| -> syn::Result { + let arg_type = match argument.is_empty() { + true => ArgType::Implicit, + false => ArgType::Explicit, + }; + + match (explicit_indexing, arg_type) { + (None, _) => explicit_indexing = Some(arg_type), + (Some(ArgType::Implicit), ArgType::Explicit) + | (Some(ArgType::Explicit), ArgType::Implicit) => { + return Err(syn::Error::new_spanned( + message, + "cannot mix implicit (`{}`) and explicit (`{0}`) placeholders in the same format string", + )); + } + _ => {} + } + + let index = match arg_type { + ArgType::Explicit => argument.parse::().map_err(|_| { + syn::Error::new_spanned( + message, + "tuple placeholders must use positional field indices", + ) + })?, + ArgType::Implicit => { + let index = implicit_index; + implicit_index += 1; + index + } + }; + + if index >= field_count { + return Err(syn::Error::new_spanned( + message, + "tuple field index is out of range", + )); + } + + Ok(index) + }; + + for character in text.chars() { + match state { + State::Text => { + output.push(character); + if character == '{' { + state = State::OpenBrace; + } + } + State::OpenBrace => { + if character == '{' { + // Escaped opening brace: keep both braces. + output.push(character); + state = State::Text; + } else if character == '}' { + // Empty placeholder `{}`. + let index = resolve_argument("")?; + output.push_str(&format!("__foundations_field_{index}")); + output.push('}'); + state = State::Text; + } else if character == ':' { + // Empty argument with a format spec `{:}`. + let index = resolve_argument("")?; + output.push_str(&format!("__foundations_field_{index}")); + output.push(':'); + state = State::FormatSpec; + } else { + state = State::Argument(character.to_string()); + } + } + State::Argument(ref mut argument) => { + if character == '}' || character == ':' { + let index = resolve_argument(argument)?; + output.push_str(&format!("__foundations_field_{index}")); + output.push(character); + state = if character == ':' { + State::FormatSpec + } else { + State::Text + }; + } else { + argument.push(character); + } + } + State::FormatSpec => { + output.push(character); + if character == '}' { + state = State::Text; + } + } + } + } + + if state != State::Text { + return Err(syn::Error::new_spanned( + message, + "unclosed format placeholder", + )); + } + + Ok(LitStr::new(&output, message.span())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ignores_unrelated_attributes() { + let plain = quote! { + #[display("{0}")] + struct Value(String); + }; + let annotated = quote! { + #[error("unrelated")] + #[display("{0}")] + struct Value(#[error("unrelated")] #[source] #[from] #[backtrace] String); + }; + assert_eq!( + expand_from_parsed(syn::parse2(plain).unwrap(), DisplayOptions::None) + .unwrap() + .to_string(), + expand_from_parsed(syn::parse2(annotated).unwrap(), DisplayOptions::None) + .unwrap() + .to_string() + ); + } + + #[test] + fn formats_enum_variants() { + let input = quote! { + enum Status { + #[display("ok")] + Ok, + #[display("error: {0}")] + Error(String), + #[display("not found")] + NotFound, + } + }; + let output = expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::None) + .unwrap() + .to_string(); + assert!(output.contains("match self")); + assert!(output.contains("Self :: Ok =>")); + assert!(output.contains("Self :: Error (__foundations_field_0) =>")); + assert!(output.contains("Self :: NotFound =>")); + } + + #[test] + fn formats_struct_fields() { + let input = quote! { + #[display("{name} is {age}")] + struct Person { + name: String, + age: u32, + } + }; + let output = expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::None) + .unwrap() + .to_string(); + assert!(output.contains("let Self { name , age } = self ;")); + assert!(output.contains("let _ = name ; let _ = age ;")); + assert!(output.contains("\"{name} is {age}\"")); + } + + #[test] + fn formats_tuple_fields() { + let input = quote! { + #[display("{0} is {1}")] + struct Point(f64, f64); + }; + let output = expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::None) + .unwrap() + .to_string(); + assert!( + output.contains("let Self (__foundations_field_0 , __foundations_field_1) = self ;") + ); + assert!(output.contains("\"{__foundations_field_0} is {__foundations_field_1}\"")); + } + + #[test] + fn handles_raw_idents() { + let input = quote! { + #[display("{type} is {match}")] + struct RawStruct { + r#type: String, + r#match: u32, + } + }; + let output = expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::None) + .unwrap() + .to_string(); + assert!(output.contains("let Self { r#type , r#match } = self ;")); + assert!(output.contains("let _ = r#type ; let _ = r#match ;")); + assert!(output.contains("\"{type} is {match}\"")); + + let input = quote! { + enum RawEnum { + #[display("type: {type}")] + Variant { r#type: String }, + } + }; + let output = expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::None) + .unwrap() + .to_string(); + assert!(output.contains("Self :: Variant { r#type } =>")); + assert!(output.contains("\"type: {type}\"")); + } + + #[test] + fn preserves_format_spec_and_escaped_braces() { + let input = quote! { + #[display("{{ {0:.2} }}")] + struct Value(f64); + }; + let output = expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::None) + .unwrap() + .to_string(); + assert!(output.contains("\"{{ {__foundations_field_0:.2} }}\"")); + } + + #[test] + fn rejects_unclosed_placeholder() { + let input = quote! { + #[display("{0")] + struct Value(String); + }; + assert!(expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::None).is_err()); + } + + #[test] + fn rejects_mixed_implicit_and_explicit_placeholders() { + for input in [ + quote! { + #[display("{} {0}")] + struct Value(String); + }, + quote! { + #[display("{0} {}")] + struct Value(String); + }, + quote! { + #[display("{1} {2} {}")] + struct Value(String); + }, + quote! { + #[display("{} {1}")] + struct Value(String); + }, + ] { + assert!(expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::None).is_err()); + } + } + + #[test] + fn rejects_unsupported_input() { + for input in [ + quote! { struct Missing; }, + quote! { #[xyzzy("old spelling")] struct InvalidAttribute; }, + quote! { #[display(transparent)] struct Transparent(String); }, + quote! { #[display("{}", .0)] struct Expression(String); }, + quote! { #[display("one")] #[display("two")] struct Duplicate; }, + quote! { #[display("{0}")] struct FieldDisplay(#[display("field")] String); }, + quote! { #[display("{2}")] struct OutOfRange(String); }, + quote! { #[display("enum")] enum Outer { Variant } }, + quote! { enum MissingVariant { Variant } }, + quote! { union Unsupported { value: u32 } }, + ] { + assert!(expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::None).is_err()); + } + } + + #[test] + fn error_derive_adds_error_impl() { + let input = quote! { + #[display("{0}")] + struct Value(String); + }; + let output = expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::ImplError) + .unwrap() + .to_string(); + assert!(output.contains("impl :: std :: fmt :: Display for Value")); + assert!(output.contains("impl :: std :: error :: Error for Value")); + } + + #[test] + fn impl_blocks_are_automatically_derived() { + let input = quote! { + #[display("{0}")] + struct Value(String); + }; + let display_output = + expand_from_parsed(syn::parse2(input.clone()).unwrap(), DisplayOptions::None) + .unwrap() + .to_string(); + assert!(display_output.contains("# [automatically_derived] impl :: std :: fmt :: Display")); + assert!( + !display_output.contains("# [automatically_derived] impl :: std :: error :: Error") + ); + + let error_output = + expand_from_parsed(syn::parse2(input).unwrap(), DisplayOptions::ImplError) + .unwrap() + .to_string(); + let automatically_derived_count = error_output.matches("# [automatically_derived]").count(); + assert_eq!(automatically_derived_count, 2); + } +} diff --git a/foundations-macros/src/lib.rs b/foundations-macros/src/lib.rs index ea046365..081107a9 100644 --- a/foundations-macros/src/lib.rs +++ b/foundations-macros/src/lib.rs @@ -1,4 +1,5 @@ mod common; +mod display; mod info_metric; mod low_cardinality_label; mod metrics; @@ -9,6 +10,70 @@ mod with_test_telemetry; use proc_macro::TokenStream; +use crate::display::DisplayOptions; + +/// Derives `Display` from literal `#[display("...")]` format strings. +/// +/// This is inspired by `thiserror` annotations, but applies generically to +/// any struct where you want something cleaner than the derived `Debug` +/// representation. +/// +/// Named fields use `{field}`; tuple fields use `{0}` or `{}`. Formatting traits +/// and explicit generic bounds are checked by Rust. No error chaining, +/// conversions, transparency, or additional formatting expressions are supported. +/// Use [`Error`] instead to also implement [`std::error::Error`]. +/// Only `display` attributes are inspected; unrelated attributes are ignored. +/// +/// ``` +/// use foundations_macros::Display; +/// +/// #[derive(Debug, Display)] +/// #[display("{value:.2}mm")] +/// struct Millimetres { value: f32 } +/// +/// assert_eq!(Millimetres { value: 12.345 }.to_string(), "12.35mm"); +/// ``` +/// +/// Unsupported `display` syntax is rejected: +/// +/// ```compile_fail +/// #[derive(foundations_macros::Display)] +/// #[display(transparent)] +/// struct Unsupported(std::io::Error); +/// ``` +/// +/// ```compile_fail +/// #[derive(foundations_macros::Display)] +/// #[display("{}", .0)] +/// struct Unsupported(String); +/// ``` +#[proc_macro_derive(Display, attributes(display))] +pub fn display(input: TokenStream) -> TokenStream { + display::expand(input, DisplayOptions::None) +} + +/// Derives `Display` and `std::error::Error` from literal `#[display("...")]` +/// format strings. +/// +/// This is identical to the [`Display`] derive, but also implements the +/// standard `Error` trait so the type can be used as a root error without a +/// manual `impl std::error::Error for ...`. +/// +/// ``` +/// use foundations_macros::Error; +/// +/// #[derive(Debug, Error)] +/// #[display("invalid value {value:?}")] +/// struct InvalidValue { value: String } +/// +/// let err: Box = Box::new(InvalidValue { value: "abc".into() }); +/// assert_eq!(err.to_string(), "invalid value \"abc\""); +/// ``` +#[proc_macro_derive(Error, attributes(display))] +pub fn error(input: TokenStream) -> TokenStream { + display::expand(input, DisplayOptions::ImplError) +} + #[proc_macro_attribute] pub fn info_metric(args: TokenStream, item: TokenStream) -> TokenStream { info_metric::expand(args, item) diff --git a/foundations/Cargo.toml b/foundations/Cargo.toml index 55d5a994..e904a8a1 100644 --- a/foundations/Cargo.toml +++ b/foundations/Cargo.toml @@ -198,6 +198,9 @@ cli = ["settings", "dep:clap"] # Enables testing-related functionality. testing = ["dep:foundations-macros"] +# Enables derive macros for `Display` and `std::error::Error`. +derive = ["dep:foundations-macros"] + # Enables the ratelimit! utility macro. ratelimit = ["dep:governor", "dep:crossbeam-utils"] diff --git a/foundations/src/derive.rs b/foundations/src/derive.rs new file mode 100644 index 00000000..4ccb5386 --- /dev/null +++ b/foundations/src/derive.rs @@ -0,0 +1,43 @@ +//! Derive macros for convenient implementation of traits. + +pub use foundations_macros::Display; +pub use foundations_macros::Error; + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Display, Debug)] + #[display("entity: {0} / {1}")] + struct DisplayTest(&'static str, i32); + + #[test] + fn display_derive_output() { + assert_eq!( + DisplayTest("matchbox", 20).to_string(), + "entity: matchbox / 20" + ); + } + + #[derive(Debug, Error)] + #[display("panik: {reason} caused by {problem}")] + struct ErrorTest { + reason: String, + problem: DisplayTest, + } + + #[test] + fn error_derive_output_and_trait() { + let err = ErrorTest { + reason: "unrequested nostalgia".to_string(), + problem: DisplayTest("haircut", 100), + }; + assert_eq!( + err.to_string(), + "panik: unrequested nostalgia caused by entity: haircut / 100" + ); + + let boxed_err: Box = Box::new(err); + assert!(boxed_err.downcast_ref::().is_some()); + } +} diff --git a/foundations/src/lib.rs b/foundations/src/lib.rs index c42d14af..82d89bed 100644 --- a/foundations/src/lib.rs +++ b/foundations/src/lib.rs @@ -102,6 +102,9 @@ pub mod security; #[cfg(feature = "ratelimit")] pub mod ratelimit; +#[cfg(feature = "derive")] +pub mod derive; + #[doc(hidden)] pub mod reexports_for_macros { #[cfg(feature = "tracing")] From 0e8c7b544a06157f42fc6fb2c80a18de69444f98 Mon Sep 17 00:00:00 2001 From: Chris Branch Date: Wed, 14 Oct 2026 20:30:51 +0100 Subject: [PATCH 2/3] metrics: add #[automatically_derived] to LowCardinalityLabel impls --- foundations-macros/src/low_cardinality_label.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/foundations-macros/src/low_cardinality_label.rs b/foundations-macros/src/low_cardinality_label.rs index 00ca2a4e..b8b77caf 100644 --- a/foundations-macros/src/low_cardinality_label.rs +++ b/foundations-macros/src/low_cardinality_label.rs @@ -53,6 +53,7 @@ fn expand_from_parsed(input: DeriveInput) -> Result { let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); Ok(quote! { + #[automatically_derived] impl #impl_generics #trait_path for #enum_name #type_generics #where_clause { const CARDINALITY: ::std::num::NonZeroUsize = ::std::num::NonZeroUsize::new(#cardinality).unwrap(); @@ -115,6 +116,7 @@ mod tests { let actual = expand_from_parsed(input).unwrap().to_string(); let expected = code_str! { + #[automatically_derived] impl ::foundations::telemetry::metrics::LowCardinalityLabel for Protocol { const CARDINALITY: ::std::num::NonZeroUsize = ::std::num::NonZeroUsize::new(3usize).unwrap(); @@ -147,6 +149,7 @@ mod tests { let actual = expand_from_parsed(input).unwrap().to_string(); let expected = code_str! { + #[automatically_derived] impl ::facade::telemetry::metrics::LowCardinalityLabel for Generic where [(); N]: Sized, From ae92b23f6f05200d7ac32a7322318397f9e7132f Mon Sep 17 00:00:00 2001 From: Chris Branch Date: Thu, 15 Oct 2026 14:10:13 +0100 Subject: [PATCH 3/3] foundations/derive: add derive to the platform-common-default feature set --- foundations/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/foundations/Cargo.toml b/foundations/Cargo.toml index e904a8a1..22e44d73 100644 --- a/foundations/Cargo.toml +++ b/foundations/Cargo.toml @@ -37,6 +37,7 @@ default = ["platform-common-default", "security"] # All non platform-specific features platform-common-default = [ + "derive", "settings", "jemalloc", "telemetry",