use std::collections::{BTreeMap, BTreeSet, VecDeque};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Ident, ItemEnum, Token};
struct Transition {
from: Ident,
to: Ident,
}
impl Parse for Transition {
fn parse(input: ParseStream) -> syn::Result<Self> {
let from: Ident = input.parse()?;
input.parse::<Token![->]>()?;
let to: Ident = input.parse()?;
Ok(Self { from, to })
}
}
struct LifecycleArgs {
initial: Ident,
terminals: Vec<Ident>,
transitions: Vec<Transition>,
}
impl Parse for LifecycleArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut initial: Option<Ident> = None;
let mut terminals: Option<Vec<Ident>> = None;
let mut transitions: Option<Vec<Transition>> = None;
while !input.is_empty() {
let key: Ident = input.parse()?;
match key.to_string().as_str() {
"initial" => {
if initial.is_some() {
return Err(syn::Error::new_spanned(
&key,
"duplicate `initial` — declare exactly one initial state",
));
}
input.parse::<Token![=]>()?;
initial = Some(input.parse()?);
}
"terminal" => {
if terminals.is_some() {
return Err(syn::Error::new_spanned(
&key,
"duplicate `terminal` — declare all terminal states in one `terminal(...)`",
));
}
let content;
syn::parenthesized!(content in input);
let idents: Punctuated<Ident, Token![,]> =
content.parse_terminated(Ident::parse, Token![,])?;
if idents.is_empty() {
return Err(syn::Error::new_spanned(
&key,
"`terminal(...)` requires at least one terminal state",
));
}
terminals = Some(idents.into_iter().collect());
}
"transitions" => {
if transitions.is_some() {
return Err(syn::Error::new_spanned(
&key,
"duplicate `transitions` — declare all edges in one `transitions(...)`",
));
}
let content;
syn::parenthesized!(content in input);
let edges: Punctuated<Transition, Token![,]> =
content.parse_terminated(Transition::parse, Token![,])?;
if edges.is_empty() {
return Err(syn::Error::new_spanned(
&key,
"`transitions(...)` requires at least one `From -> To` edge",
));
}
transitions = Some(edges.into_iter().collect());
}
other => {
return Err(syn::Error::new_spanned(
&key,
format!(
"unknown `#[lifecycle]` argument `{other}` — expected `initial`, `terminal`, or `transitions`"
),
));
}
}
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
let initial = initial.ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
"missing required `initial = <Variant>` in `#[lifecycle(...)]`",
)
})?;
let terminals = terminals.ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
"missing required `terminal(<Variant>, ...)` in `#[lifecycle(...)]`",
)
})?;
let transitions = transitions.ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
"missing required `transitions(<From> -> <To>, ...)` in `#[lifecycle(...)]`",
)
})?;
Ok(Self {
initial,
terminals,
transitions,
})
}
}
fn snake_case(ident: &Ident) -> String {
let name = ident.to_string();
let name = name.strip_prefix("r#").unwrap_or(&name);
let mut out = String::with_capacity(name.len() + 4);
let mut prev_lower_or_digit = false;
for ch in name.chars() {
if ch.is_uppercase() {
if prev_lower_or_digit {
out.push('_');
}
for lower in ch.to_lowercase() {
out.push(lower);
}
prev_lower_or_digit = false;
} else {
out.push(ch);
prev_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
}
}
out
}
pub fn lifecycle_macro(attr: TokenStream, item: TokenStream) -> TokenStream {
let item_enum: ItemEnum = match syn::parse2(item) {
Ok(e) => e,
Err(_) => {
return syn::Error::new(
proc_macro2::Span::call_site(),
"#[lifecycle] may only be applied to an enum",
)
.to_compile_error();
}
};
let args: LifecycleArgs = match syn::parse2(attr) {
Ok(a) => a,
Err(err) => return err.to_compile_error(),
};
match expand(&item_enum, &args) {
Ok(generated) => quote! {
#item_enum
#generated
},
Err(err) => {
let compile_err = err.to_compile_error();
quote! {
#item_enum
#compile_err
}
}
}
}
fn expand(item_enum: &ItemEnum, args: &LifecycleArgs) -> syn::Result<TokenStream> {
let enum_ident = &item_enum.ident;
let variants: Vec<&Ident> = item_enum.variants.iter().map(|v| &v.ident).collect();
let is_variant = |ident: &Ident| variants.contains(&ident);
if !is_variant(&args.initial) {
return Err(syn::Error::new_spanned(
&args.initial,
format!(
"`initial` state `{}` is not a variant of enum `{}`",
args.initial, enum_ident
),
));
}
for t in &args.terminals {
if !is_variant(t) {
return Err(syn::Error::new_spanned(
t,
format!("`terminal` state `{t}` is not a variant of enum `{enum_ident}`"),
));
}
}
let mut seen_edges: Vec<(String, String)> = Vec::new();
for tr in &args.transitions {
if !is_variant(&tr.from) {
return Err(syn::Error::new_spanned(
&tr.from,
format!(
"transition source `{}` is not a variant of enum `{}`",
tr.from, enum_ident
),
));
}
if !is_variant(&tr.to) {
return Err(syn::Error::new_spanned(
&tr.to,
format!(
"transition target `{}` is not a variant of enum `{}`",
tr.to, enum_ident
),
));
}
if args.terminals.contains(&tr.from) {
return Err(syn::Error::new_spanned(
&tr.from,
format!(
"terminal state `{}` cannot have an outgoing transition to `{}`",
tr.from, tr.to
),
));
}
let key = (tr.from.to_string(), tr.to.to_string());
if seen_edges.contains(&key) {
return Err(syn::Error::new_spanned(
&tr.to,
format!("duplicate transition `{} -> {}`", tr.from, tr.to),
));
}
seen_edges.push(key);
}
check_graph_soundness(item_enum, args, &variants)?;
let metadata = build_metadata(item_enum, args, &variants);
let module = build_module(item_enum, args);
Ok(quote! {
#metadata
#module
})
}
fn check_graph_soundness(
item_enum: &ItemEnum,
args: &LifecycleArgs,
variants: &[&Ident],
) -> syn::Result<()> {
let initial = args.initial.to_string();
let terminals: BTreeSet<String> = args.terminals.iter().map(ToString::to_string).collect();
let edges: Vec<(String, String)> = args
.transitions
.iter()
.map(|tr| (tr.from.to_string(), tr.to.to_string()))
.collect();
let mut forward: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
let mut reverse: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (from, to) in &edges {
forward.entry(from).or_default().push(to);
reverse.entry(to).or_default().push(from);
}
let reachable = walk(std::iter::once(initial.as_str()), &forward);
let live = walk(terminals.iter().map(String::as_str), &reverse);
let mut error: Option<syn::Error> = None;
let mut push = |err: syn::Error| match &mut error {
Some(acc) => acc.combine(err),
none => *none = Some(err),
};
for variant in variants {
let name = variant.to_string();
if !reachable.contains(name.as_str()) {
push(syn::Error::new_spanned(
variant,
format!(
"state `{name}` is unreachable from initial state `{initial}` of lifecycle \
`{}` — add a transition into it, or remove the variant",
item_enum.ident
),
));
} else if !terminals.contains(&name) && !live.contains(name.as_str()) {
push(syn::Error::new_spanned(
variant,
format!(
"state `{name}` is a non-terminal dead-end of lifecycle `{}`: no declared \
transition path reaches a terminal state — add an outgoing transition, or \
declare it terminal",
item_enum.ident
),
));
}
}
error.map_or(Ok(()), Err)
}
fn walk<'a>(
starts: impl Iterator<Item = &'a str>,
adj: &BTreeMap<&'a str, Vec<&'a str>>,
) -> BTreeSet<&'a str> {
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut queue: VecDeque<&str> = VecDeque::new();
for start in starts {
if seen.insert(start) {
queue.push_back(start);
}
}
while let Some(node) = queue.pop_front() {
for &next in adj.get(node).into_iter().flatten() {
if seen.insert(next) {
queue.push_back(next);
}
}
}
seen
}
fn build_metadata(item_enum: &ItemEnum, args: &LifecycleArgs, variants: &[&Ident]) -> TokenStream {
let enum_ident = &item_enum.ident;
let initial = &args.initial;
let terminal_paths = args.terminals.iter().map(|t| quote!(#enum_ident::#t));
let state_paths = variants.iter().map(|v| quote!(#enum_ident::#v));
let transition_pairs = args
.transitions
.iter()
.map(|tr| {
let from = &tr.from;
let to = &tr.to;
quote!((#enum_ident::#from, #enum_ident::#to))
})
.collect::<Vec<_>>();
let transition_name_triples = args.transitions.iter().map(|tr| {
let from = variant_name_str(&tr.from);
let to = variant_name_str(&tr.to);
quote!((#from, #to, ::core::option::Option::None))
});
let match_arms = args.transitions.iter().map(|tr| {
let from = &tr.from;
let to = &tr.to;
quote!((#enum_ident::#from, #enum_ident::#to) => true)
});
quote! {
impl #enum_ident {
pub const LIFECYCLE_INITIAL: #enum_ident = #enum_ident::#initial;
pub const LIFECYCLE_TERMINALS: &'static [#enum_ident] = &[#(#terminal_paths),*];
pub const LIFECYCLE_STATES: &'static [#enum_ident] = &[#(#state_paths),*];
pub const LIFECYCLE_TRANSITIONS: &'static [(#enum_ident, #enum_ident)] = &[
#(#transition_pairs),*
];
pub fn can_transition_to(&self, to: &#enum_ident) -> bool {
match (self, to) {
#(#match_arms,)*
_ => false,
}
}
}
impl ::autumn_web::Lifecycle for #enum_ident {
const STATE_MACHINE_TRANSITIONS: &'static [(
&'static str,
&'static str,
::core::option::Option<&'static str>,
)] = &[
#(#transition_name_triples),*
];
}
}
}
fn variant_name_str(ident: &Ident) -> String {
let name = ident.to_string();
name.strip_prefix("r#").unwrap_or(&name).to_string()
}
fn build_module(item_enum: &ItemEnum, args: &LifecycleArgs) -> TokenStream {
let enum_ident = &item_enum.ident;
let mod_ident = format_ident!("{}", snake_case(enum_ident));
let initial = &args.initial;
let variants: Vec<&Ident> = item_enum.variants.iter().map(|v| &v.ident).collect();
let marker_structs = variants.iter().map(|v| {
quote! {
#[allow(non_snake_case)]
pub struct #v;
}
});
let state_impls = variants.iter().map(|v| {
let name = v.to_string();
let name = name.strip_prefix("r#").unwrap_or(&name).to_string();
quote! {
impl sealed::Sealed for #v {}
impl State for #v {
const NAME: &'static str = #name;
const VALUE: super::#enum_ident = super::#enum_ident::#v;
}
}
});
let start_impl = quote! {
impl Machine<#initial> {
pub fn start() -> Machine<#initial> {
Machine { _state: ::core::marker::PhantomData }
}
}
};
let mut sources: Vec<&Ident> = Vec::new();
for tr in &args.transitions {
if !sources.contains(&&tr.from) {
sources.push(&tr.from);
}
}
let edge_impls = sources.iter().map(|source| {
let methods = args
.transitions
.iter()
.filter(|tr| &tr.from == *source)
.map(|tr| {
let target = &tr.to;
let method = format_ident!("to_{}", snake_case(target));
quote! {
pub fn #method(self) -> Machine<#target> {
Machine { _state: ::core::marker::PhantomData }
}
}
});
quote! {
impl Machine<#source> {
#(#methods)*
}
}
});
quote! {
pub mod #mod_ident {
use core::marker::PhantomData;
#(#marker_structs)*
mod sealed {
pub trait Sealed {}
}
pub trait State: sealed::Sealed {
const NAME: &'static str;
const VALUE: super::#enum_ident;
}
#(#state_impls)*
pub struct Machine<S: State> {
_state: PhantomData<S>,
}
#start_impl
impl<S: State> Machine<S> {
pub fn current(&self) -> super::#enum_ident {
S::VALUE
}
}
#(#edge_impls)*
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
fn expand_ok(attr: TokenStream, item: TokenStream) -> String {
let item_enum: ItemEnum = syn::parse2(item).expect("enum should parse");
let args: LifecycleArgs = syn::parse2(attr).expect("args should parse");
expand(&item_enum, &args)
.expect("expansion should succeed")
.to_string()
}
fn sample_enum() -> TokenStream {
quote! {
pub enum ArticleState {
Draft,
Published,
Archived,
}
}
}
fn sample_attr() -> TokenStream {
quote! {
initial = Draft,
terminal(Archived),
transitions(
Draft -> Published,
Published -> Archived,
Published -> Draft,
)
}
}
#[test]
fn emits_metadata_consts_with_expected_names_and_entries() {
let out = expand_ok(sample_attr(), sample_enum());
assert!(out.contains("LIFECYCLE_INITIAL"), "{out}");
assert!(out.contains("LIFECYCLE_TERMINALS"), "{out}");
assert!(out.contains("LIFECYCLE_STATES"), "{out}");
assert!(out.contains("LIFECYCLE_TRANSITIONS"), "{out}");
assert!(
out.contains("LIFECYCLE_INITIAL : ArticleState = ArticleState :: Draft"),
"{out}"
);
assert!(out.contains("& [ArticleState :: Archived]"), "{out}");
assert!(
out.contains(
"& [ArticleState :: Draft , ArticleState :: Published , ArticleState :: Archived]"
),
"{out}"
);
}
#[test]
fn emits_lifecycle_trait_impl_with_string_transition_table() {
let out = expand_ok(sample_attr(), sample_enum());
assert!(
out.contains("impl :: autumn_web :: Lifecycle for ArticleState"),
"{out}"
);
assert!(out.contains("const STATE_MACHINE_TRANSITIONS"), "{out}");
assert!(
out.contains("(\"Draft\" , \"Published\" , :: core :: option :: Option :: None)"),
"{out}"
);
assert!(
out.contains("(\"Published\" , \"Archived\" , :: core :: option :: Option :: None)"),
"{out}"
);
}
#[test]
fn lifecycle_transition_table_strips_raw_identifier_prefix() {
let item = quote! {
pub enum KindState {
Draft,
r#type,
}
};
let attr = quote! {
initial = Draft,
terminal(r#type),
transitions(Draft -> r#type)
};
let out = expand_ok(attr, item);
assert!(
out.contains("(\"Draft\" , \"type\" , :: core :: option :: Option :: None)"),
"{out}"
);
}
#[test]
fn can_transition_to_matches_declared_edges() {
let out = expand_ok(sample_attr(), sample_enum());
assert!(out.contains("fn can_transition_to"), "{out}");
assert!(
out.contains("(ArticleState :: Draft , ArticleState :: Published) => true"),
"{out}"
);
assert!(out.contains("_ => false"), "{out}");
}
#[test]
fn transition_method_exists_on_source_state() {
let out = expand_ok(sample_attr(), sample_enum());
assert!(out.contains("pub mod article_state"), "{out}");
assert!(out.contains("fn to_published"), "{out}");
assert!(out.contains("fn to_archived"), "{out}");
assert!(out.contains("fn to_draft"), "{out}");
}
#[test]
fn terminal_state_has_no_outgoing_methods() {
let out = expand_ok(sample_attr(), sample_enum());
assert!(
!out.contains("impl Machine < Archived >"),
"Archived is terminal and must have no outgoing method impl:\n{out}"
);
}
#[test]
fn start_only_on_initial_state() {
let out = expand_ok(sample_attr(), sample_enum());
assert!(out.contains("impl Machine < Draft >"), "{out}");
assert!(out.contains("fn start"), "{out}");
assert_eq!(out.matches("fn start").count(), 1, "{out}");
}
#[test]
fn undeclared_initial_is_error() {
let item_enum: ItemEnum = syn::parse2(sample_enum()).unwrap();
let args: LifecycleArgs = syn::parse2(quote! {
initial = Nonexistent,
terminal(Archived),
transitions(Draft -> Published)
})
.unwrap();
let err = expand(&item_enum, &args).unwrap_err();
assert!(
err.to_string().contains("Nonexistent"),
"error should name the offending ident: {err}"
);
}
#[test]
fn undeclared_terminal_is_error() {
let item_enum: ItemEnum = syn::parse2(sample_enum()).unwrap();
let args: LifecycleArgs = syn::parse2(quote! {
initial = Draft,
terminal(Ghost),
transitions(Draft -> Published)
})
.unwrap();
let err = expand(&item_enum, &args).unwrap_err();
assert!(err.to_string().contains("Ghost"), "{err}");
}
#[test]
fn undeclared_transition_endpoint_is_error() {
let item_enum: ItemEnum = syn::parse2(sample_enum()).unwrap();
let args: LifecycleArgs = syn::parse2(quote! {
initial = Draft,
terminal(Archived),
transitions(Draft -> Nowhere)
})
.unwrap();
let err = expand(&item_enum, &args).unwrap_err();
assert!(err.to_string().contains("Nowhere"), "{err}");
}
#[test]
fn terminal_source_transition_is_error() {
let item_enum: ItemEnum = syn::parse2(sample_enum()).unwrap();
let args: LifecycleArgs = syn::parse2(quote! {
initial = Draft,
terminal(Archived),
transitions(
Draft -> Archived,
Archived -> Draft,
)
})
.unwrap();
let err = expand(&item_enum, &args).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("terminal state `Archived`"),
"error should name the offending terminal: {msg}"
);
assert!(
msg.contains("Draft"),
"error should name the transition target: {msg}"
);
}
#[test]
fn duplicate_transition_edge_is_error() {
let item_enum: ItemEnum = syn::parse2(sample_enum()).unwrap();
let args: LifecycleArgs = syn::parse2(quote! {
initial = Draft,
terminal(Archived),
transitions(
Draft -> Published,
Draft -> Published,
)
})
.unwrap();
let err = expand(&item_enum, &args).unwrap_err();
assert!(err.to_string().contains("duplicate"), "{err}");
}
fn expand_err(attr: TokenStream, item: TokenStream) -> Vec<String> {
let item_enum: ItemEnum = syn::parse2(item).expect("enum should parse");
let args: LifecycleArgs = syn::parse2(attr).expect("args should parse");
let err = expand(&item_enum, &args).expect_err("expansion should fail");
err.into_iter().map(|e| e.to_string()).collect()
}
#[test]
fn unreachable_state_is_a_compile_error_naming_the_state() {
let errs = expand_err(
quote! {
initial = Draft,
terminal(Archived),
transitions(Draft -> Archived)
},
quote! {
pub enum ArticleState { Draft, Published, Archived }
},
);
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(errs[0].contains("`Published`"), "{errs:?}");
assert!(errs[0].contains("unreachable"), "{errs:?}");
assert!(errs[0].contains("`Draft`"), "{errs:?}");
}
#[test]
fn every_unreachable_state_is_reported_in_declaration_order() {
let errs = expand_err(
quote! {
initial = Draft,
terminal(Archived),
transitions(Draft -> Archived)
},
quote! {
pub enum ArticleState { Draft, Review, Published, Archived }
},
);
assert_eq!(errs.len(), 2, "{errs:?}");
assert!(errs[0].contains("`Review`"), "{errs:?}");
assert!(errs[1].contains("`Published`"), "{errs:?}");
}
#[test]
fn non_terminal_dead_end_is_a_compile_error_naming_the_state() {
let errs = expand_err(
quote! {
initial = Draft,
terminal(Archived),
transitions(
Draft -> Published,
Draft -> Archived,
)
},
quote! {
pub enum ArticleState { Draft, Published, Archived }
},
);
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(errs[0].contains("`Published`"), "{errs:?}");
assert!(errs[0].contains("dead-end"), "{errs:?}");
}
#[test]
fn a_cycle_that_never_reaches_a_terminal_is_a_dead_end() {
let errs = expand_err(
quote! {
initial = Draft,
terminal(Archived),
transitions(
Draft -> Review,
Review -> Revise,
Revise -> Review,
Draft -> Archived,
)
},
quote! {
pub enum ArticleState { Draft, Review, Revise, Archived }
},
);
assert_eq!(errs.len(), 2, "{errs:?}");
assert!(errs[0].contains("`Review`"), "{errs:?}");
assert!(errs[1].contains("`Revise`"), "{errs:?}");
}
#[test]
fn an_unreachable_state_is_not_also_reported_as_a_dead_end() {
let errs = expand_err(
quote! {
initial = Draft,
terminal(Archived),
transitions(
Draft -> Archived,
Orphan -> Stuck,
)
},
quote! {
pub enum ArticleState { Draft, Orphan, Stuck, Archived }
},
);
assert_eq!(errs.len(), 2, "{errs:?}");
assert!(errs.iter().all(|e| e.contains("unreachable")), "{errs:?}");
}
#[test]
fn unreachable_and_dead_end_are_reported_together_in_declaration_order() {
let errs = expand_err(
quote! {
initial = Draft,
terminal(Archived),
transitions(Review -> Archived)
},
quote! {
pub enum ArticleState { Draft, Review, Archived }
},
);
assert_eq!(errs.len(), 3, "{errs:?}");
assert!(
errs[0].contains("`Draft`") && errs[0].contains("dead-end"),
"{errs:?}"
);
assert!(
errs[1].contains("`Review`") && errs[1].contains("unreachable"),
"{errs:?}"
);
assert!(
errs[2].contains("`Archived`") && errs[2].contains("unreachable"),
"{errs:?}"
);
}
#[test]
fn a_self_loop_is_not_a_way_out() {
let errs = expand_err(
quote! {
initial = Draft,
terminal(Archived),
transitions(
Draft -> Review,
Review -> Review,
Draft -> Archived,
)
},
quote! {
pub enum ArticleState { Draft, Review, Archived }
},
);
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(
errs[0].contains("`Review`") && errs[0].contains("dead-end"),
"{errs:?}"
);
}
#[test]
fn a_cycle_back_to_an_earlier_state_stays_sound() {
let out = expand_ok(sample_attr(), sample_enum());
assert!(out.contains("pub mod article_state"), "{out}");
}
#[test]
fn a_branch_to_a_second_terminal_stays_sound() {
let out = expand_ok(
quote! {
initial = Draft,
terminal(Archived, Rejected),
transitions(
Draft -> Published,
Draft -> Rejected,
Published -> Archived,
)
},
quote! {
pub enum ArticleState { Draft, Published, Archived, Rejected }
},
);
assert!(out.contains("pub mod article_state"), "{out}");
}
fn parse_args_err(attr: TokenStream) -> String {
let res: syn::Result<LifecycleArgs> = syn::parse2(attr);
match res {
Ok(_) => panic!("expected a parse error"),
Err(err) => err.to_string(),
}
}
#[test]
fn missing_initial_is_error() {
let err = parse_args_err(quote! {
terminal(Archived),
transitions(Draft -> Published)
});
assert!(err.contains("initial"), "{err}");
}
#[test]
fn missing_terminal_is_error() {
let err = parse_args_err(quote! {
initial = Draft,
transitions(Draft -> Published)
});
assert!(err.contains("terminal"), "{err}");
}
#[test]
fn missing_transitions_is_error() {
let err = parse_args_err(quote! {
initial = Draft,
terminal(Archived)
});
assert!(err.contains("transitions"), "{err}");
}
#[test]
fn snake_case_naming_for_multi_word_variants() {
let item = quote! {
pub enum ReviewState {
Draft,
InReview,
Approved,
}
};
let attr = quote! {
initial = Draft,
terminal(Approved),
transitions(
Draft -> InReview,
InReview -> Approved,
)
};
let out = expand_ok(attr, item);
assert!(out.contains("pub mod review_state"), "{out}");
assert!(out.contains("fn to_in_review"), "{out}");
}
#[test]
fn snake_case_helper_cases() {
assert_eq!(snake_case(&format_ident!("Draft")), "draft");
assert_eq!(snake_case(&format_ident!("InReview")), "in_review");
assert_eq!(snake_case(&format_ident!("ArticleState")), "article_state");
assert_eq!(snake_case(&format_ident!("HTTPState")), "httpstate");
assert_eq!(snake_case(&format_ident!("Step2Review")), "step2_review");
}
}