use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{parse2, Expr, Fields, Ident, ItemStruct, Meta, Token};
use syn::punctuated::Punctuated;
use crate::prop_type;
struct UClassArgs {
parent_path: syn::Path, parent_name: String, }
fn parse_uclass_args(attr: TokenStream) -> syn::Result<UClassArgs> {
let metas: Punctuated<Meta, Token![,]> =
parse2::<syn::parse::Nothing>(attr.clone())
.map(|_| Punctuated::new())
.unwrap_or_else(|_| {
syn::parse::Parser::parse2(
Punctuated::<Meta, Token![,]>::parse_terminated,
attr,
)
.unwrap_or_default()
});
let mut parent_path: Option<syn::Path> = None;
for meta in &metas {
if let Meta::NameValue(nv) = meta
&& nv.path.is_ident("parent")
{
if let Expr::Path(expr_path) = &nv.value {
parent_path = Some(expr_path.path.clone());
} else {
return Err(syn::Error::new_spanned(
&nv.value,
"`parent` must be a type path, not a string literal.\n\n\
Example: #[uclass(parent = Actor)]",
));
}
}
}
let parent_path = parent_path.ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
"#[uclass] requires a `parent` attribute specifying the UE parent class.\n\n\
Example:\n\
\x20 #[uclass(parent = Actor)]\n\
\x20 pub struct MyActor { ... }\n\n\
Common parents: Actor, Pawn, Character, PlayerController,\n\
\x20 GameModeBase, ActorComponent, SceneComponent",
)
})?;
let parent_name = parent_path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
Ok(UClassArgs { parent_path, parent_name })
}
#[derive(Default)]
struct UPropertyArgs {
blueprint_read_write: bool,
blueprint_read_only: bool,
edit_anywhere: bool,
default_expr: Option<Expr>,
}
fn parse_uproperty_args(attr: &syn::Attribute) -> syn::Result<UPropertyArgs> {
let mut args = UPropertyArgs::default();
let nested = attr.parse_args_with(
Punctuated::<Meta, Token![,]>::parse_terminated,
)?;
for meta in &nested {
match meta {
Meta::Path(p) => {
if p.is_ident("BlueprintReadWrite") {
args.blueprint_read_write = true;
} else if p.is_ident("BlueprintReadOnly") {
args.blueprint_read_only = true;
} else if p.is_ident("EditAnywhere") {
args.edit_anywhere = true;
}
}
Meta::NameValue(nv)
if nv.path.is_ident("default") => {
args.default_expr = Some(nv.value.clone());
}
_ => {}
}
}
Ok(args)
}
struct ComponentArgs {
is_root: bool,
attach_to: Option<String>,
socket: Option<String>,
}
fn parse_component_args(attr: &syn::Attribute) -> syn::Result<ComponentArgs> {
let mut args = ComponentArgs {
is_root: false,
attach_to: None,
socket: None,
};
let nested = match attr.parse_args_with(
Punctuated::<Meta, Token![,]>::parse_terminated,
) {
Ok(n) => n,
Err(_) => return Ok(args),
};
for meta in &nested {
match meta {
Meta::Path(p) => {
if p.is_ident("root") {
args.is_root = true;
}
}
Meta::NameValue(nv)
if nv.path.is_ident("attach") => {
if let Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s), ..
}) = &nv.value
{
args.attach_to = Some(s.value());
} else {
return Err(syn::Error::new_spanned(
&nv.value,
"attach must be a string literal, e.g. attach = \"root_scene\"",
));
}
}
Meta::NameValue(nv)
if nv.path.is_ident("socket") => {
if let Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s), ..
}) = &nv.value
{
args.socket = Some(s.value());
} else {
return Err(syn::Error::new_spanned(
&nv.value,
"socket must be a string literal, e.g. socket = \"SpringEndpoint\"",
));
}
}
_ => {}
}
}
if args.socket.is_some() && args.attach_to.is_none() {
return Err(syn::Error::new_spanned(
attr,
"socket needs attach, e.g. attach = \"camera_boom\", socket = \"SpringEndpoint\"",
));
}
Ok(args)
}
struct UPropertyField {
ident: Ident,
ty: syn::Type,
args: UPropertyArgs,
}
struct RustPrivateField {
ident: Ident,
ty: syn::Type,
}
struct ComponentField {
ident: Ident,
component_type: syn::Path,
is_root: bool,
attach_to: Option<String>,
socket: Option<String>,
}
pub fn expand_uclass(attr: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
let args = parse_uclass_args(attr)?;
let input: ItemStruct = parse2(item)?;
let struct_name = &input.ident;
let struct_vis = &input.vis;
let struct_name_str = struct_name.to_string();
let mut uprops: Vec<UPropertyField> = Vec::new();
let mut rust_fields: Vec<RustPrivateField> = Vec::new();
let mut components: Vec<ComponentField> = Vec::new();
let fields = match &input.fields {
Fields::Named(f) => &f.named,
_ => {
return Err(syn::Error::new_spanned(
&input,
"#[uclass] requires a struct with named fields.\n\n\
Example:\n\
\x20 #[uclass(parent = Actor)]\n\
\x20 pub struct MyActor {\n\
\x20 #[uproperty(BlueprintReadWrite, EditAnywhere)]\n\
\x20 health: f32,\n\
\x20 }",
));
}
};
for field in fields {
let field_ident = field.ident.as_ref().unwrap().clone();
let field_ty = field.ty.clone();
let comp_attr = field
.attrs
.iter()
.find(|a| a.path().is_ident("component"));
let uprop_attr = field
.attrs
.iter()
.find(|a| a.path().is_ident("uproperty"));
if let Some(attr) = comp_attr {
let cargs = parse_component_args(attr)?;
let component_type = match &field_ty {
syn::Type::Path(tp) => tp.path.clone(),
_ => {
return Err(syn::Error::new_spanned(
&field_ty,
"#[component] field type must be a path (e.g. SceneComponent)",
));
}
};
components.push(ComponentField {
ident: field_ident,
component_type,
is_root: cargs.is_root,
attach_to: cargs.attach_to,
socket: cargs.socket,
});
} else if let Some(attr) = uprop_attr {
let pargs = parse_uproperty_args(attr)?;
let Some(info) = prop_type::map_type(&field_ty) else {
return Err(syn::Error::new_spanned(
&field_ty,
"unsupported uproperty type: supported are bool/i32/i64/u8/f32/f64, \
UObjectRef<T>, SubclassOf<T> and UeArray of those",
));
};
if matches!(info.kind, prop_type::PropKind::Struct { .. }) {
return Err(syn::Error::new_spanned(
&field_ty,
"UStructRef is only supported as a #[ufunction] parameter, not as a uproperty",
));
}
if pargs.default_expr.is_some() && !matches!(info.kind, prop_type::PropKind::Scalar { .. }) {
return Err(syn::Error::new_spanned(
&field_ty,
"`default = ...` is only supported for bool/i32/i64/u8/f32/f64 properties; \
object, class and array properties are set in a Blueprint child",
));
}
uprops.push(UPropertyField {
ident: field_ident,
ty: field_ty,
args: pargs,
});
} else {
rust_fields.push(RustPrivateField {
ident: field_ident,
ty: field_ty,
});
}
}
let rust_data_name = format_ident!("__{}RustData", struct_name);
let class_handle_name = format_ident!("__RUSTEAL_CLASS_HANDLE_{}", to_screaming_snake(&struct_name_str));
let register_fn_name = format_ident!("__rusteal_register_{}", to_snake_case(&struct_name_str));
let finalize_fn_name = format_ident!("__rusteal_finalize_{}", to_snake_case(&struct_name_str));
let type_id_value = prop_type::fnv1a_hash(&struct_name_str);
let user_struct = quote! {
#struct_vis struct #struct_name {
#[doc(hidden)]
pub __obj: ::rusteal_runtime::ffi::UObjectHandle,
#[doc(hidden)]
pub __rust_data: *mut #rust_data_name,
}
};
let rust_data_fields: Vec<TokenStream> = rust_fields
.iter()
.map(|f| {
let ident = &f.ident;
let ty = &f.ty;
quote! { pub #ident: #ty, }
})
.collect();
let rust_data_defaults: Vec<TokenStream> = rust_fields
.iter()
.map(|f| {
let ident = &f.ident;
quote! { #ident: Default::default(), }
})
.collect();
let rust_data_struct = quote! {
#[doc(hidden)]
pub struct #rust_data_name {
#(#rust_data_fields)*
}
impl Default for #rust_data_name {
fn default() -> Self {
Self {
#(#rust_data_defaults)*
}
}
}
};
let static_handle = quote! {
#[doc(hidden)]
pub static #class_handle_name: std::sync::OnceLock<::rusteal_runtime::ffi::UClassHandle> = std::sync::OnceLock::new();
};
let ue_class_impl = quote! {
impl ::rusteal_runtime::runtime::UeClass for #struct_name {
fn static_class() -> ::rusteal_runtime::ffi::UClassHandle {
*#class_handle_name.get().expect(concat!("Failed to find UClass for ", stringify!(#struct_name), " — was it registered?"))
}
}
};
let mut accessor_methods: Vec<TokenStream> = Vec::new();
for prop in &uprops {
let info = prop_type::map_type(&prop.ty).unwrap();
let field_ident = &prop.ident;
let ue_name = prop_type::to_pascal_case(&field_ident.to_string());
let ue_name_bytes = ue_name.as_bytes();
let ue_name_len = ue_name.len() as u32;
let rust_ty = &info.rust_type;
let find_prop = quote! {
static PROP: std::sync::OnceLock<::rusteal_runtime::ffi::FPropertyHandle> = std::sync::OnceLock::new();
let prop = *PROP.get_or_init(|| unsafe {
::rusteal_runtime::runtime::ffi_dispatch::reflection_find_property(
<Self as ::rusteal_runtime::runtime::UeClass>::static_class(),
[#(#ue_name_bytes),*].as_ptr(),
#ue_name_len,
)
});
};
let (getter_body, setter_body) = match &info.kind {
prop_type::PropKind::Scalar { getter_fn, setter_fn, zero_expr } => {
let getter_dispatch = format_ident!("property_{}", getter_fn);
let setter_dispatch = format_ident!("property_{}", setter_fn);
(
quote! {
let mut val: #rust_ty = #zero_expr;
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::#getter_dispatch(self.__obj, prop, &mut val); }
val
},
Some(quote! {
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::#setter_dispatch(self.__obj, prop, val); }
}),
)
}
prop_type::PropKind::Object { .. } => (
quote! {
let mut h = ::rusteal_runtime::ffi::UObjectHandle::null();
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::property_get_object(self.__obj, prop, &mut h); }
unsafe { <#rust_ty>::from_raw(h) }
},
Some(quote! {
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::property_set_object(self.__obj, prop, val.raw()); }
}),
),
prop_type::PropKind::Class { .. } => (
quote! {
let mut h = ::rusteal_runtime::ffi::UObjectHandle::null();
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::property_get_object(self.__obj, prop, &mut h); }
unsafe { <#rust_ty>::from_raw(::rusteal_runtime::ffi::UClassHandle(h.0)) }
},
Some(quote! {
unsafe {
::rusteal_runtime::runtime::ffi_dispatch::property_set_object(
self.__obj, prop, ::rusteal_runtime::ffi::UObjectHandle(val.raw().0),
);
}
}),
),
prop_type::PropKind::Array { .. } => (
quote! { <#rust_ty>::new(self.__obj, prop) },
None,
),
prop_type::PropKind::Struct { .. } => unreachable!("rejected when the field was parsed"),
};
let getter_ident = format_ident!("{}", field_ident);
accessor_methods.push(quote! {
pub fn #getter_ident(&self) -> #rust_ty {
#find_prop
#getter_body
}
});
if let Some(setter_body) = setter_body
&& !prop.args.blueprint_read_only
{
let setter_ident = format_ident!("set_{}", field_ident);
accessor_methods.push(quote! {
pub fn #setter_ident(&self, val: #rust_ty) {
#find_prop
#setter_body
}
});
}
}
for f in &rust_fields {
let ident = &f.ident;
let ty = &f.ty;
let setter_ident = format_ident!("set_{}", ident);
accessor_methods.push(quote! {
pub fn #ident(&self) -> #ty {
unsafe { (*self.__rust_data).#ident }
}
pub fn #setter_ident(&mut self, val: #ty) {
unsafe { (*self.__rust_data).#ident = val; }
}
});
}
for comp in &components {
let field_ident = &comp.ident;
let comp_type = &comp.component_type;
let comp_name = prop_type::to_pascal_case(&field_ident.to_string());
let comp_name_bytes = comp_name.as_bytes();
let comp_name_len = comp_name.len() as u32;
accessor_methods.push(quote! {
pub fn #field_ident(&self) -> ::rusteal_runtime::runtime::RustealResult<::rusteal_runtime::runtime::UObjectRef<#comp_type>> {
let h = unsafe {
::rusteal_runtime::runtime::ffi_dispatch::reify_find_default_subobject(
self.__obj,
[#(#comp_name_bytes),*].as_ptr(), #comp_name_len,
)
};
if h.is_null() {
return Err(::rusteal_runtime::runtime::RustealError::InvalidOperation(
concat!("subobject '", #comp_name, "' not found").into()
));
}
Ok(unsafe { ::rusteal_runtime::runtime::UObjectRef::from_raw(h) })
}
});
}
let as_ref_parent = &args.parent_path;
accessor_methods.push(quote! {
pub fn as_ref(&self) -> ::rusteal_runtime::runtime::UObjectRef<#as_ref_parent> {
unsafe { ::rusteal_runtime::runtime::UObjectRef::from_raw(self.__obj) }
}
});
accessor_methods.push(quote! {
pub fn from_obj(obj: ::rusteal_runtime::runtime::UObjectRef<impl ::rusteal_runtime::runtime::UeClass>) -> ::rusteal_runtime::runtime::RustealResult<Self> {
let handle = obj.checked()?.raw();
let is_a = unsafe {
::rusteal_runtime::runtime::ffi_dispatch::core_is_a(handle, <Self as ::rusteal_runtime::runtime::UeClass>::static_class())
};
if !is_a {
return Err(::rusteal_runtime::runtime::RustealError::InvalidCast);
}
let rust_data = ::rusteal_runtime::runtime::reify_registry::get_instance_data(handle)
as *mut #rust_data_name;
if rust_data.is_null() {
return Err(::rusteal_runtime::runtime::RustealError::InvalidOperation("no rust data for reified cast".into()));
}
Ok(Self { __obj: handle, __rust_data: rust_data })
}
});
let accessors_impl = quote! {
impl #struct_name {
#(#accessor_methods)*
}
};
let parent_path = &args.parent_path;
let parent_name = args.parent_name.as_str();
let parent_name_bytes = parent_name.as_bytes();
let parent_name_len = parent_name.len() as u32;
let struct_name_bytes = struct_name_str.as_bytes();
let struct_name_byte_len = struct_name_str.len() as u32;
let mut add_prop_stmts: Vec<TokenStream> = Vec::new();
let mut cdo_default_stmts: Vec<TokenStream> = Vec::new();
for prop in &uprops {
let info = prop_type::map_type(&prop.ty).unwrap();
let ue_name = prop_type::to_pascal_case(&prop.ident.to_string());
let ue_name_bytes = ue_name.as_bytes();
let ue_name_len = ue_name.len() as u32;
let prop_type_expr = &info.prop_type_expr;
let prop_var = format_ident!("_prop_{}", prop.ident);
let mut flag_parts = Vec::new();
if prop.args.blueprint_read_write || prop.args.blueprint_read_only {
flag_parts.push(quote! { ::rusteal_runtime::ffi::CPF_BLUEPRINT_VISIBLE });
}
if prop.args.blueprint_read_only {
flag_parts.push(quote! { ::rusteal_runtime::ffi::CPF_BLUEPRINT_READ_ONLY });
flag_parts.push(quote! { ::rusteal_runtime::ffi::CPF_EDIT });
flag_parts.push(quote! { ::rusteal_runtime::ffi::CPF_EDIT_CONST });
}
if prop.args.edit_anywhere || prop.args.blueprint_read_write {
flag_parts.push(quote! { ::rusteal_runtime::ffi::CPF_EDIT });
}
if flag_parts.is_empty() {
flag_parts.push(quote! { 0u64 });
}
let flags_expr = quote! { #(#flag_parts)|* };
let extra_expr = match info.extra_fields() {
Some(fields) => quote! {
&::rusteal_runtime::ffi::RustealReifyPropExtra {
#fields
..::core::default::Default::default()
}
},
None => quote! { std::ptr::null() },
};
add_prop_stmts.push(quote! {
let #prop_var = unsafe {
::rusteal_runtime::runtime::ffi_dispatch::reify_add_property(
class,
[#(#ue_name_bytes),*].as_ptr(),
#ue_name_len,
#prop_type_expr as u32,
#flags_expr,
#extra_expr,
)
};
});
if let Some(ref default_expr) = prop.args.default_expr
&& let prop_type::PropKind::Scalar { setter_fn, .. } = &info.kind
{
let setter_dispatch = format_ident!("property_{}", setter_fn);
cdo_default_stmts.push(quote! {
if !#prop_var.is_null() {
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::#setter_dispatch(cdo, #prop_var, #default_expr); }
}
});
}
}
let mut add_comp_stmts: Vec<TokenStream> = Vec::new();
for comp in &components {
let comp_type = &comp.component_type;
let comp_name = prop_type::to_pascal_case(&comp.ident.to_string());
let comp_name_bytes = comp_name.as_bytes();
let comp_name_len = comp_name.len() as u32;
let mut flags: u32 = 0;
if comp.is_root {
flags |= 1; }
let name_arg = |name: Option<String>| match name {
Some(name) => {
let bytes = name.into_bytes();
let len = bytes.len() as u32;
(quote! { [#(#bytes),*].as_ptr() }, quote! { #len })
}
None => (quote! { std::ptr::null() }, quote! { 0u32 }),
};
let (attach_ptr, attach_len) =
name_arg(comp.attach_to.as_deref().map(prop_type::to_pascal_case));
let (socket_ptr, socket_len) = name_arg(comp.socket.clone());
add_comp_stmts.push(quote! {
{
let comp_class = <#comp_type as ::rusteal_runtime::runtime::UeClass>::static_class();
unsafe {
::rusteal_runtime::runtime::ffi_dispatch::reify_add_default_subobject(
class,
[#(#comp_name_bytes),*].as_ptr(), #comp_name_len,
comp_class,
#flags,
#attach_ptr, #attach_len,
#socket_ptr, #socket_len,
);
}
}
});
}
let mut finalize_cdo_stmts: Vec<TokenStream> = Vec::new();
for prop in &uprops {
if let Some(ref default_expr) = prop.args.default_expr
&& let prop_type::PropKind::Scalar { setter_fn, .. } = prop_type::map_type(&prop.ty).unwrap().kind
{
let ue_name = prop_type::to_pascal_case(&prop.ident.to_string());
let ue_name_bytes = ue_name.as_bytes();
let ue_name_len = ue_name.len() as u32;
let setter_dispatch = format_ident!("property_{}", setter_fn);
finalize_cdo_stmts.push(quote! {
{
let prop = unsafe {
::rusteal_runtime::runtime::ffi_dispatch::reflection_find_property(
class,
[#(#ue_name_bytes),*].as_ptr(),
#ue_name_len,
)
};
if !prop.is_null() {
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::#setter_dispatch(cdo, prop, #default_expr); }
}
}
});
}
}
let register_fn = quote! {
#[doc(hidden)]
pub fn #register_fn_name() {
const TYPE_ID: u64 = #type_id_value;
::rusteal_runtime::runtime::reify_registry::register_type(
TYPE_ID,
::rusteal_runtime::runtime::reify_registry::RustTypeInfo {
name: #struct_name_str,
construct_fn: || {
Box::into_raw(Box::new(#rust_data_name::default())) as *mut u8
},
drop_fn: |ptr| {
if !ptr.is_null() {
let _ = unsafe { Box::from_raw(ptr as *mut #rust_data_name) };
}
},
},
);
let parent = unsafe {
::rusteal_runtime::runtime::ffi_dispatch::reflection_find_class(
[#(#parent_name_bytes),*].as_ptr(),
#parent_name_len,
)
};
if parent.is_null() {
let msg = concat!("[Rusteal] ", stringify!(#struct_name), ": failed to find parent class '", #parent_name, "'");
let bytes = msg.as_bytes();
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::logging_log(2, bytes.as_ptr(), bytes.len() as u32); }
return;
}
let class = unsafe {
::rusteal_runtime::runtime::ffi_dispatch::reify_create_class(
[#(#struct_name_bytes),*].as_ptr(),
#struct_name_byte_len,
parent,
TYPE_ID,
)
};
if class.is_null() {
let msg = concat!("[Rusteal] ", stringify!(#struct_name), ": create_class failed");
let bytes = msg.as_bytes();
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::logging_log(2, bytes.as_ptr(), bytes.len() as u32); }
return;
}
#class_handle_name.set(class).ok();
#(#add_prop_stmts)*
#(#add_comp_stmts)*
}
};
let finalize_fn = quote! {
#[doc(hidden)]
pub fn #finalize_fn_name() {
let class = match #class_handle_name.get() {
Some(&c) if !c.is_null() => c,
_ => return,
};
let result = unsafe { ::rusteal_runtime::runtime::ffi_dispatch::reify_finalize_class(class) };
if result != ::rusteal_runtime::ffi::RustealErrorCode::Ok {
let msg = concat!("[Rusteal] ", stringify!(#struct_name), ": finalize_class failed");
let bytes = msg.as_bytes();
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::logging_log(2, bytes.as_ptr(), bytes.len() as u32); }
return;
}
let cdo = unsafe { ::rusteal_runtime::runtime::ffi_dispatch::reify_get_cdo(class) };
if !cdo.is_null() {
#(#finalize_cdo_stmts)*
}
let msg = concat!("[Rusteal] ", stringify!(#struct_name), " registered successfully");
let bytes = msg.as_bytes();
unsafe { ::rusteal_runtime::runtime::ffi_dispatch::logging_log(0, bytes.as_ptr(), bytes.len() as u32); }
}
};
let comp_type_checks: Vec<TokenStream> = components
.iter()
.map(|c| {
let ct = &c.component_type;
quote! { _assert_ue_class::<#ct>(); }
})
.collect();
let parent_check = quote! {
const _: () = {
fn _rusteal_parent_check() {
fn _assert_ue_class<T: ::rusteal_runtime::runtime::UeClass>() {}
_assert_ue_class::<#parent_path>();
#(#comp_type_checks)*
}
};
};
let has_parent_impl = quote! {
impl ::rusteal_runtime::runtime::HasParent for #struct_name {
type Parent = #parent_path;
}
};
let deref_impl = quote! {
impl std::ops::Deref for #struct_name {
type Target = ::rusteal_runtime::runtime::UObjectRef<#parent_path>;
fn deref(&self) -> &::rusteal_runtime::runtime::UObjectRef<#parent_path> {
unsafe { &*(&self.__obj as *const ::rusteal_runtime::ffi::UObjectHandle as *const ::rusteal_runtime::runtime::UObjectRef<#parent_path>) }
}
}
};
Ok(quote! {
#user_struct
#rust_data_struct
#static_handle
#ue_class_impl
#has_parent_impl
#parent_check
#deref_impl
#accessors_impl
#register_fn
#finalize_fn
::rusteal_runtime::__inventory::submit! {
::rusteal_runtime::runtime::reify_registry::ClassRegistration {
register: #register_fn_name,
finalize: #finalize_fn_name,
}
}
})
}
pub(crate) fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
result.push('_');
}
result.push(c.to_lowercase().next().unwrap());
} else {
result.push(c);
}
}
result
}
pub(crate) fn to_screaming_snake(s: &str) -> String {
to_snake_case(s).to_uppercase()
}