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