A better Rust ATProto crate
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}