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.
32/// First normalizes to snake_case to detect word boundaries, then capitalizes each word.
33pub fn to_pascal(s: &str) -> String {
34    let snake = to_snake(s);
35    let mut out = String::new();
36    let mut capitalize = true;
37
38    for c in snake.chars() {
39        if c == '_' {
40            capitalize = true;
41        } else if capitalize {
42            out.extend(c.to_uppercase());
43            capitalize = false;
44        } else {
45            out.push(c);
46        }
47    }
48
49    out
50}
51
52/// Converts a string to snake_case.
53pub fn to_snake(s: &str) -> String {
54    let mut out = String::new();
55    let chars: Vec<char> = s.chars().collect();
56
57    for (i, &c) in chars.iter().enumerate() {
58        if c.is_uppercase() {
59            let prev_lower = i > 0 && chars[i - 1].is_lowercase();
60            let next_lower = i + 1 < chars.len() && chars[i + 1].is_lowercase();
61            let prev_upper = i > 0 && chars[i - 1].is_uppercase();
62
63            if prev_lower || (next_lower && prev_upper) {
64                out.push('_');
65            }
66
67            out.extend(c.to_lowercase());
68        } else if c == '_' {
69            if !out.is_empty() && !out.ends_with('_') {
70                out.push('_');
71            }
72        } else {
73            out.push(c);
74        }
75    }
76
77    out
78}
79
80/// Converts a string to camelCase.
81pub fn to_camel(s: &str) -> String {
82    let pascal = to_pascal(s);
83    let mut chars = pascal.chars();
84
85    match chars.next() {
86        None => String::new(),
87        Some(c) => c.to_lowercase().collect::<String>() + chars.as_str(),
88    }
89}
90
91/// Converts a string to SCREAMING_SNAKE_CASE.
92pub fn to_screaming(s: &str) -> String {
93    to_snake(s).to_uppercase()
94}
95
96/// Converts a string to kebab-case.
97pub fn to_kebab(s: &str) -> String {
98    to_snake(s).replace('_', "-")
99}
100
101/// Converts a string or ident to PascalCase.
102///
103/// # Usage
104///
105/// - `pascal!("hello_world")` → `"HelloWorld"` (`String`)
106/// - `pascal!(ident => ident)` → PascalCase `syn::Ident`
107/// - `pascal!(token_stream => token_stream)` → PascalCase last ident in path
108#[macro_export]
109macro_rules! pascal {
110    ($ident:expr => ident) => {
111        syn::Ident::new(
112            &$crate::case::to_pascal(&$ident.to_string()),
113            $ident.span(),
114        )
115    };
116    ($ts:expr => token_stream) => {{
117        let __tokens: Vec<proc_macro2::TokenTree> = $ts.clone().into_iter().collect();
118        let mut __out = proc_macro2::TokenStream::new();
119
120        for (i, __tt) in __tokens.iter().enumerate() {
121            match __tt {
122                proc_macro2::TokenTree::Ident(__ident) => {
123                    let __is_last_ident = !__tokens[i + 1..]
124                        .iter()
125                        .any(|t| matches!(t, proc_macro2::TokenTree::Ident(_)));
126
127                    if __is_last_ident {
128                        quote::ToTokens::to_tokens(
129                            &$crate::pascal!(__ident => ident),
130                            &mut __out,
131                        );
132                    } else {
133                        quote::ToTokens::to_tokens(__ident, &mut __out);
134                    }
135                }
136                __other => {
137                    quote::ToTokens::to_tokens(__other, &mut __out);
138                }
139            }
140        }
141
142        __out
143    }};
144    ($s:expr) => {
145        $crate::case::to_pascal($s)
146    };
147}
148
149/// Converts a string or ident to snake_case.
150///
151/// - `snake!("HelloWorld")` → `"hello_world"` (`String`)
152/// - `snake!(ident => ident)` → snake_case `syn::Ident`
153#[macro_export]
154macro_rules! snake {
155    ($ident:expr => ident) => {
156        syn::Ident::new(&$crate::case::to_snake(&$ident.to_string()), $ident.span())
157    };
158    ($s:expr) => {
159        $crate::case::to_snake($s)
160    };
161}
162
163/// Converts a string or ident to camelCase.
164///
165/// - `camel!("hello_world")` → `"helloWorld"` (`String`)
166/// - `camel!(ident => ident)` → camelCase `syn::Ident`
167#[macro_export]
168macro_rules! camel {
169    ($ident:expr => ident) => {
170        syn::Ident::new(&$crate::case::to_camel(&$ident.to_string()), $ident.span())
171    };
172    ($s:expr) => {
173        $crate::case::to_camel($s)
174    };
175}
176
177/// Converts a string or ident to SCREAMING_SNAKE_CASE.
178///
179/// - `screaming!("HelloWorld")` → `"HELLO_WORLD"` (`String`)
180/// - `screaming!(ident => ident)` → SCREAMING_SNAKE_CASE `syn::Ident`
181#[macro_export]
182macro_rules! screaming {
183    ($ident:expr => ident) => {
184        syn::Ident::new(
185            &$crate::case::to_screaming(&$ident.to_string()),
186            $ident.span(),
187        )
188    };
189    ($s:expr) => {
190        $crate::case::to_screaming($s)
191    };
192}
193
194/// Converts a string or ident to kebab-case.
195///
196/// - `kebab!("HelloWorld")` → `"hello-world"` (`String`)
197#[macro_export]
198macro_rules! kebab {
199    ($s:expr) => {
200        $crate::case::to_kebab($s)
201    };
202}