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