Skip to main content

zyn_core/
case.rs

1//! Case conversion functions and macros.
2//!
3//! zyn ships its own case conversion — no dependency on `heck`.
4//!
5//! # Functions
6//!
7//! ```ignore
8//! use zyn_core::case;
9//!
10//! assert_eq!(case::to_snake("HelloWorld"),   "hello_world");
11//! assert_eq!(case::to_pascal("hello_world"), "HelloWorld");
12//! assert_eq!(case::to_camel("hello_world"),  "helloWorld");
13//! assert_eq!(case::to_screaming("fooBar"),   "FOO_BAR");
14//! assert_eq!(case::to_kebab("FooBar"),       "foo-bar");
15//! ```
16//!
17//! # Macros
18//!
19//! ```ignore
20//! // String form
21//! let s: String = zyn_core::snake!("HelloWorld");
22//! // → "hello_world"
23//!
24//! // Ident form (preserves span)
25//! let id: syn::Ident = zyn_core::pascal!(my_ident => ident);
26//! // my_ident = `foo_bar` → `FooBar`
27//! ```
28
29/// Converts a string to PascalCase.
30///
31/// Handles snake_case, camelCase, PascalCase, and SCREAMING_SNAKE_CASE inputs.
32pub fn to_pascal(s: &str) -> String {
33    let bytes = s.as_bytes();
34    let mut out = String::with_capacity(s.len());
35    let mut capitalize = true;
36
37    for i in 0..bytes.len() {
38        let c = bytes[i];
39
40        if c == b'_' {
41            if !out.is_empty() {
42                capitalize = true;
43            }
44        } else if c.is_ascii_uppercase() {
45            let prev_lower = i > 0 && bytes[i - 1].is_ascii_lowercase();
46            let next_lower = i + 1 < bytes.len() && bytes[i + 1].is_ascii_lowercase();
47            let prev_upper = i > 0 && bytes[i - 1].is_ascii_uppercase();
48
49            if capitalize || prev_lower || (next_lower && prev_upper) {
50                out.push(c as char);
51            } else {
52                out.push((c + 32) as char);
53            }
54
55            capitalize = false;
56        } else if capitalize {
57            out.push((c - 32) as char);
58            capitalize = false;
59        } else {
60            out.push(c as char);
61        }
62    }
63
64    out
65}
66
67/// Converts a string to snake_case.
68pub fn to_snake(s: &str) -> String {
69    let bytes = s.as_bytes();
70    let mut out = String::with_capacity(s.len() + 4);
71
72    for i in 0..bytes.len() {
73        let c = bytes[i];
74
75        if c.is_ascii_uppercase() {
76            let prev_lower = i > 0 && bytes[i - 1].is_ascii_lowercase();
77            let next_lower = i + 1 < bytes.len() && bytes[i + 1].is_ascii_lowercase();
78            let prev_upper = i > 0 && bytes[i - 1].is_ascii_uppercase();
79
80            if prev_lower || (next_lower && prev_upper) {
81                out.push('_');
82            }
83
84            out.push((c + 32) as char);
85        } else if c == b'_' {
86            if !out.is_empty() && !out.ends_with('_') {
87                out.push('_');
88            }
89        } else {
90            out.push(c as char);
91        }
92    }
93
94    out
95}
96
97/// Converts a string to camelCase.
98pub fn to_camel(s: &str) -> String {
99    let mut pascal = to_pascal(s);
100
101    if let Some(first) = pascal.as_bytes().first()
102        && first.is_ascii_uppercase()
103    {
104        unsafe {
105            pascal.as_bytes_mut()[0] = first + 32;
106        }
107    }
108
109    pascal
110}
111
112/// Converts a string to SCREAMING_SNAKE_CASE.
113pub fn to_screaming(s: &str) -> String {
114    let bytes = s.as_bytes();
115    let mut out = String::with_capacity(s.len() + 4);
116
117    for i in 0..bytes.len() {
118        let c = bytes[i];
119
120        if c.is_ascii_uppercase() {
121            let prev_lower = i > 0 && bytes[i - 1].is_ascii_lowercase();
122            let next_lower = i + 1 < bytes.len() && bytes[i + 1].is_ascii_lowercase();
123            let prev_upper = i > 0 && bytes[i - 1].is_ascii_uppercase();
124
125            if prev_lower || (next_lower && prev_upper) {
126                out.push('_');
127            }
128
129            out.push(c as char);
130        } else if c == b'_' {
131            if !out.is_empty() && !out.ends_with('_') {
132                out.push('_');
133            }
134        } else {
135            out.push((c - 32) as char);
136        }
137    }
138
139    out
140}
141
142/// Converts a string to kebab-case.
143pub fn to_kebab(s: &str) -> String {
144    let bytes = s.as_bytes();
145    let mut out = String::with_capacity(s.len() + 4);
146
147    for i in 0..bytes.len() {
148        let c = bytes[i];
149
150        if c.is_ascii_uppercase() {
151            let prev_lower = i > 0 && bytes[i - 1].is_ascii_lowercase();
152            let next_lower = i + 1 < bytes.len() && bytes[i + 1].is_ascii_lowercase();
153            let prev_upper = i > 0 && bytes[i - 1].is_ascii_uppercase();
154
155            if prev_lower || (next_lower && prev_upper) {
156                out.push('-');
157            }
158
159            out.push((c + 32) as char);
160        } else if c == b'_' {
161            if !out.is_empty() && !out.ends_with('-') {
162                out.push('-');
163            }
164        } else {
165            out.push(c as char);
166        }
167    }
168
169    out
170}
171
172/// Converts a string or ident to PascalCase.
173///
174/// # Usage
175///
176/// - `pascal!("hello_world")` → `"HelloWorld"` (`String`)
177/// - `pascal!(ident => ident)` → PascalCase `syn::Ident`
178/// - `pascal!(token_stream => token_stream)` → PascalCase last ident in path
179#[macro_export]
180macro_rules! pascal {
181    ($ident:expr => ident) => {
182        syn::Ident::new(
183            &$crate::case::to_pascal(&$ident.to_string()),
184            $ident.span(),
185        )
186    };
187    ($ts:expr => token_stream) => {{
188        let __tokens: Vec<proc_macro2::TokenTree> = $ts.clone().into_iter().collect();
189        let mut __out = proc_macro2::TokenStream::new();
190
191        for (i, __tt) in __tokens.iter().enumerate() {
192            match __tt {
193                proc_macro2::TokenTree::Ident(__ident) => {
194                    let __is_last_ident = !__tokens[i + 1..]
195                        .iter()
196                        .any(|t| matches!(t, proc_macro2::TokenTree::Ident(_)));
197
198                    if __is_last_ident {
199                        quote::ToTokens::to_tokens(
200                            &$crate::pascal!(__ident => ident),
201                            &mut __out,
202                        );
203                    } else {
204                        quote::ToTokens::to_tokens(__ident, &mut __out);
205                    }
206                }
207                __other => {
208                    quote::ToTokens::to_tokens(__other, &mut __out);
209                }
210            }
211        }
212
213        __out
214    }};
215    ($s:expr) => {
216        $crate::case::to_pascal($s)
217    };
218}
219
220/// Converts a string or ident to snake_case.
221///
222/// - `snake!("HelloWorld")` → `"hello_world"` (`String`)
223/// - `snake!(ident => ident)` → snake_case `syn::Ident`
224#[macro_export]
225macro_rules! snake {
226    ($ident:expr => ident) => {
227        syn::Ident::new(&$crate::case::to_snake(&$ident.to_string()), $ident.span())
228    };
229    ($s:expr) => {
230        $crate::case::to_snake($s)
231    };
232}
233
234/// Converts a string or ident to camelCase.
235///
236/// - `camel!("hello_world")` → `"helloWorld"` (`String`)
237/// - `camel!(ident => ident)` → camelCase `syn::Ident`
238#[macro_export]
239macro_rules! camel {
240    ($ident:expr => ident) => {
241        syn::Ident::new(&$crate::case::to_camel(&$ident.to_string()), $ident.span())
242    };
243    ($s:expr) => {
244        $crate::case::to_camel($s)
245    };
246}
247
248/// Converts a string or ident to SCREAMING_SNAKE_CASE.
249///
250/// - `screaming!("HelloWorld")` → `"HELLO_WORLD"` (`String`)
251/// - `screaming!(ident => ident)` → SCREAMING_SNAKE_CASE `syn::Ident`
252#[macro_export]
253macro_rules! screaming {
254    ($ident:expr => ident) => {
255        syn::Ident::new(
256            &$crate::case::to_screaming(&$ident.to_string()),
257            $ident.span(),
258        )
259    };
260    ($s:expr) => {
261        $crate::case::to_screaming($s)
262    };
263}
264
265/// Converts a string or ident to kebab-case.
266///
267/// - `kebab!("HelloWorld")` → `"hello-world"` (`String`)
268#[macro_export]
269macro_rules! kebab {
270    ($s:expr) => {
271        $crate::case::to_kebab($s)
272    };
273}