A better Rust ATProto crate
at main 185 lines 6.3 kB view raw
1use heck::ToPascalCase; 2use jacquard_common::CowStr; 3use proc_macro2::TokenStream; 4use quote::quote; 5 6/// Rust keywords that need escaping with r# prefix in module paths 7const RUST_KEYWORDS: &[&str] = &[ 8 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for", 9 "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", 10 "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where", 11 "while", // Reserved keywords 12 "abstract", "become", "box", "do", "final", "macro", "override", "priv", "try", "typeof", 13 "unsized", "virtual", "yield", // 2018+ edition keywords 14 "async", "await", "dyn", 15]; 16 17/// Check if a string is a Rust keyword 18#[inline] 19fn is_rust_keyword(s: &str) -> bool { 20 RUST_KEYWORDS.contains(&s) 21} 22/// Convert a value string to a valid Rust variant name 23pub(super) fn value_to_variant_name(value: &str) -> String { 24 // Remove leading special chars and convert to pascal case 25 let clean = value.trim_start_matches(|c: char| !c.is_alphanumeric()); 26 let variant = clean.replace('-', "_").to_pascal_case(); 27 28 // Prefix with underscore if starts with digit 29 if variant.chars().next().map_or(false, |c| c.is_ascii_digit()) { 30 format!("_{}", variant) 31 } else if variant.is_empty() { 32 "Unknown".to_string() 33 } else { 34 variant 35 } 36} 37 38/// Convert a knownValues entry to a valid Rust variant name. 39/// For NSID#fragment values (e.g., "tools.ozone.team.defs#roleAdmin"), 40/// extracts just the fragment part for the variant name. 41/// For plain values (e.g., "create"), uses the whole value. 42pub(super) fn known_value_to_variant_name(value: &str) -> String { 43 // If contains #, use just the fragment part for the variant name 44 let name_part = if let Some(idx) = value.rfind('#') { 45 &value[idx + 1..] 46 } else { 47 value 48 }; 49 value_to_variant_name(name_part) 50} 51 52/// Check if a string is already a valid identifier (alphanumeric + underscore, not starting with digit) 53#[inline] 54fn is_valid_identifier(s: &str) -> bool { 55 if s.is_empty() { 56 return false; 57 } 58 59 let mut chars = s.chars(); 60 let first = chars.next().unwrap(); 61 62 // Must start with letter or underscore 63 if !first.is_ascii_alphabetic() && first != '_' { 64 return false; 65 } 66 67 // Rest must be alphanumeric or underscore 68 chars.all(|c| c.is_ascii_alphanumeric() || c == '_') 69} 70 71/// Sanitize a string to be safe for identifiers and filenames, returning CowStr. 72/// Borrows if already valid, allocates if modifications needed. 73pub(super) fn sanitize_name_cow(s: &str) -> CowStr<'_> { 74 if is_valid_identifier(s) { 75 return CowStr::Borrowed(s); 76 } 77 78 if s.is_empty() { 79 return CowStr::Owned(jacquard_common::deps::smol_str::SmolStr::new_static( 80 "unknown", 81 )); 82 } 83 84 // Replace invalid characters with underscores 85 let mut sanitized: String = s 86 .chars() 87 .map(|c| { 88 if c.is_alphanumeric() || c == '_' { 89 c 90 } else { 91 '_' 92 } 93 }) 94 .collect(); 95 96 // Ensure it doesn't start with a digit 97 if sanitized 98 .chars() 99 .next() 100 .map_or(false, |c| c.is_ascii_digit()) 101 { 102 sanitized = format!("_{}", sanitized); 103 } 104 105 CowStr::Owned(sanitized.into()) 106} 107 108/// Sanitize a string to be safe for identifiers and filenames, always returning String. 109/// Convenience wrapper around sanitize_name_cow for existing callsites. 110pub(super) fn sanitize_name(s: &str) -> String { 111 sanitize_name_cow(s).to_string() 112} 113 114/// Build namespace prefix from first two NSID segments (e.g., "com", "atproto" → "com_atproto") 115pub(super) fn namespace_prefix(first: &str, second: &str) -> String { 116 format!("{}_{}", sanitize_name_cow(first), sanitize_name_cow(second)) 117} 118 119/// Escape a Rust keyword with r# prefix for use in paths 120fn escape_keyword_for_path(s: &str) -> std::borrow::Cow<'_, str> { 121 // crate, self, super, and Self are valid in path contexts 122 if is_rust_keyword(s) && !matches!(s, "crate" | "self" | "super" | "Self") { 123 std::borrow::Cow::Owned(format!("r#{}", s)) 124 } else { 125 std::borrow::Cow::Borrowed(s) 126 } 127} 128 129/// Join NSID segments into a module path (e.g., ["repo", "admin"] → "repo::admin") 130pub(super) fn join_module_path(segments: &[&str]) -> String { 131 segments 132 .iter() 133 .map(|s| { 134 let sanitized = sanitize_name_cow(s); 135 escape_keyword_for_path(&sanitized).into_owned() 136 }) 137 .collect::<Vec<_>>() 138 .join("::") 139} 140 141/// Join already-processed strings into a Rust module path (e.g., ["crate", "foo", "Bar"] → "crate::foo::Bar") 142pub(super) fn join_path_parts(parts: &[impl AsRef<str>]) -> String { 143 parts 144 .iter() 145 .map(|p| escape_keyword_for_path(p.as_ref()).into_owned()) 146 .collect::<Vec<_>>() 147 .join("::") 148} 149 150/// Create an identifier, using raw identifier if necessary for keywords 151pub fn make_ident(s: &str) -> syn::Ident { 152 if s.is_empty() { 153 eprintln!("Warning: Empty identifier encountered, using 'unknown' as fallback"); 154 return syn::Ident::new("unknown", proc_macro2::Span::call_site()); 155 } 156 157 let sanitized = sanitize_name(s); 158 159 // Try to parse as ident, fall back to raw ident if needed 160 syn::parse_str::<syn::Ident>(&sanitized).unwrap_or_else(|_| { 161 // only print if the sanitization actually changed the name 162 // for types where the name is a keyword, will prepend 'r#' 163 if s != sanitized { 164 eprintln!( 165 "Warning: Invalid identifier '{}' sanitized to '{}'", 166 s, sanitized 167 ); 168 syn::Ident::new(&sanitized, proc_macro2::Span::call_site()) 169 } else { 170 syn::Ident::new_raw(&sanitized, proc_macro2::Span::call_site()) 171 } 172 }) 173} 174 175/// Generate doc comment from optional description 176pub(super) fn generate_doc_comment(desc: Option<&CowStr>) -> TokenStream { 177 if let Some(description) = desc { 178 let desc_str = format!(" {description}"); 179 quote! { 180 #[doc = #desc_str] 181 } 182 } else { 183 quote! {} 184 } 185}