use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Error, Ident, Meta};
#[proc_macro_derive(Representable, attributes(representable))]
pub fn derive_representable(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = input.ident;
match parse_human_readable_fn(&input.attrs, &name) {
Ok(human_readable_fn) => {
let expanded = quote! {
impl ::rialo_cli_representation::HumanReadable for #name {
fn human_readable(&self) -> String {
#human_readable_fn
}
}
impl ::rialo_cli_representation::Representable for #name {}
};
TokenStream::from(expanded)
}
Err(err) => {
let error = err.to_compile_error();
TokenStream::from(error)
}
}
}
fn validate_function_name(function_name: &str, span: proc_macro2::Span) -> Result<(), Error> {
if function_name.trim().is_empty() {
return Err(Error::new(
span,
"Function name cannot be empty or whitespace-only",
));
}
if function_name.len() > 100 {
return Err(Error::new(
span,
"Function name is too long (maximum 100 characters)",
));
}
let dangerous_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'];
if let Some(ch) = function_name
.chars()
.find(|&c| dangerous_chars.contains(&c))
{
return Err(Error::new(
span,
format!("Function name contains invalid character '{ch}'. Function names must be valid Rust identifiers.")
));
}
if function_name.chars().any(|c| c.is_control()) {
return Err(Error::new(
span,
"Function name contains control characters",
));
}
if let Some(first_char) = function_name.chars().next() {
if !first_char.is_alphabetic() && first_char != '_' {
return Err(Error::new(
span,
"Function name must start with a letter or underscore",
));
}
}
if !function_name
.chars()
.all(|c| c.is_alphanumeric() || c == '_')
{
return Err(Error::new(
span,
"Function name must contain only letters, numbers, and underscores",
));
}
if is_reserved_keyword(function_name) {
return Err(Error::new(
span,
format!("Function name '{function_name}' is a reserved Rust keyword"),
));
}
Ok(())
}
fn is_reserved_keyword(function_name: &str) -> bool {
let core_keywords = [
"fn",
"struct",
"enum",
"trait",
"impl",
"mod",
"use",
"extern",
"crate",
"type",
"const",
"static",
"let",
"mut",
"ref",
"move",
"dyn",
"async",
"await",
"if",
"else",
"match",
"loop",
"while",
"for",
"in",
"return",
"break",
"continue",
"pub",
"priv",
"unsafe",
"where",
"as",
"box",
"do",
"final",
"override",
"self",
"Self",
"super",
"macro",
"macro_rules",
"try",
"union",
];
core_keywords.contains(&function_name)
}
fn parse_human_readable_fn(
attrs: &[syn::Attribute],
_struct_name: &Ident,
) -> Result<syn::Expr, Error> {
for attr in attrs {
if attr.path().is_ident("representable") {
if let Meta::List(meta_list) = &attr.meta {
for nested in meta_list
.parse_args_with(
syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated,
)
.unwrap_or_default()
{
if let syn::Meta::NameValue(name_value) = nested {
if name_value.path.is_ident("human_readable") {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit_str),
..
}) = &name_value.value
{
let function_name = &lit_str.value();
let span = lit_str.span();
validate_function_name(function_name, span)?;
let fn_name = Ident::new(function_name, span);
return Ok(syn::parse_quote! { #fn_name(self) });
}
}
}
}
}
}
}
Ok(syn::parse_quote! {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
})
}
#[cfg(test)]
mod tests {
use proc_macro2::Span;
use syn::parse_quote;
use super::*;
#[test]
fn test_validate_function_name_valid() {
let valid_names = [
"my_function",
"myFunction",
"my_function_123",
"_private_function",
"f",
"a1b2c3",
];
for name in &valid_names {
assert!(
validate_function_name(name, Span::call_site()).is_ok(),
"Function name '{name}' should be valid"
);
}
}
#[test]
fn test_validate_function_name_invalid_characters() {
let invalid_names = [
("my/function", "path separator"),
("my\\function", "backslash"),
("my:function", "colon"),
("my*function", "asterisk"),
("my?function", "question mark"),
("my\"function", "quote"),
("my<function", "less than"),
("my>function", "greater than"),
("my|function", "pipe"),
("my\0function", "null byte"),
];
for (name, description) in &invalid_names {
let result = validate_function_name(name, Span::call_site());
assert!(
result.is_err(),
"Function name '{name}' ({description}) should be invalid"
);
}
}
#[test]
fn test_validate_function_name_invalid_start() {
let invalid_names = ["1function", "123function", ".function", "-function"];
for name in &invalid_names {
let result = validate_function_name(name, Span::call_site());
assert!(
result.is_err(),
"Function name '{name}' should be invalid (invalid start)"
);
}
}
#[test]
fn test_validate_function_name_reserved_keywords() {
let reserved_keywords = [
"fn", "struct", "enum", "impl", "trait", "mod", "use", "pub", "priv", "let", "mut",
"const", "static", "if", "else", "match", "loop", "while", "for", "in", "return",
"break", "continue", "as", "where", "unsafe", "async", "await", "dyn", "move", "ref",
"self", "Self", "super",
];
for keyword in &reserved_keywords {
let result = validate_function_name(keyword, Span::call_site());
assert!(
result.is_err(),
"Reserved keyword '{keyword}' should be invalid"
);
}
}
#[test]
fn test_validate_function_name_length_constraints() {
let empty_names = ["", " ", "\t", "\n", " \t \n "];
for name in &empty_names {
let result = validate_function_name(name, Span::call_site());
assert!(
result.is_err(),
"Empty/whitespace name '{name}' should be invalid"
);
}
let long_name = "a".repeat(101);
let result = validate_function_name(&long_name, Span::call_site());
assert!(result.is_err(), "Very long name should be invalid");
}
#[test]
fn test_validate_function_name_control_characters() {
let control_chars = [
'\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08', '\x09', '\x0A',
'\x0B', '\x0C', '\x0D', '\x0E', '\x0F', '\x10', '\x11', '\x12', '\x13', '\x14', '\x15',
'\x16', '\x17', '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', '\x1E', '\x1F', '\x7F',
];
for &ch in &control_chars {
let name = format!("my{ch}function");
let result = validate_function_name(&name, Span::call_site());
assert!(
result.is_err(),
"Function name with control character '{ch}' should be invalid"
);
}
}
#[test]
fn test_parse_human_readable_fn_valid() {
let attrs = vec![parse_quote! {
#[representable(human_readable = "my_display_fn")]
}];
let struct_name = Ident::new("MyStruct", Span::call_site());
let result = parse_human_readable_fn(&attrs, &struct_name);
assert!(result.is_ok());
if let Ok(expr) = result {
let expr_string = quote!(#expr).to_string();
assert!(expr_string.contains("my_display_fn"));
}
}
#[test]
fn test_parse_human_readable_fn_invalid() {
let attrs = vec![parse_quote! {
#[representable(human_readable = "my/function")]
}];
let struct_name = Ident::new("MyStruct", Span::call_site());
let result = parse_human_readable_fn(&attrs, &struct_name);
assert!(result.is_err());
}
#[test]
fn test_parse_human_readable_fn_no_attribute() {
let attrs = vec![];
let struct_name = Ident::new("MyStruct", Span::call_site());
let result = parse_human_readable_fn(&attrs, &struct_name);
assert!(result.is_ok());
if let Ok(expr) = result {
let expr_string = quote!(#expr).to_string();
assert!(expr_string.contains("serde_json") || expr_string.contains("unwrap_or_else"));
}
}
#[test]
fn test_parse_human_readable_fn_reserved_keyword() {
let attrs = vec![parse_quote! {
#[representable(human_readable = "fn")]
}];
let struct_name = Ident::new("MyStruct", Span::call_site());
let result = parse_human_readable_fn(&attrs, &struct_name);
assert!(result.is_err());
}
}