Skip to main content

qubit_case/
case_style.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! # Case Style
9//!
10//! Defines ASCII identifier naming styles and conversion helpers.
11
12use crate::CaseStyleError;
13
14use std::str::FromStr;
15
16/// XML hyphenated variable naming style, such as `lower-hyphen`.
17pub const LOWER_HYPHEN: CaseStyle = CaseStyle::LowerHyphen;
18
19/// C++/Python variable naming style, such as `lower_underscore`.
20pub const LOWER_UNDERSCORE: CaseStyle = CaseStyle::LowerUnderscore;
21
22/// Java variable naming style, such as `lowerCamel`.
23pub const LOWER_CAMEL: CaseStyle = CaseStyle::LowerCamel;
24
25/// Java and C++ class naming style, such as `UpperCamel`.
26pub const UPPER_CAMEL: CaseStyle = CaseStyle::UpperCamel;
27
28/// Java and C++ constant naming style, such as `UPPER_UNDERSCORE`.
29pub const UPPER_UNDERSCORE: CaseStyle = CaseStyle::UpperUnderscore;
30
31const VALUES: [CaseStyle; 5] = [
32    LOWER_HYPHEN,
33    LOWER_UNDERSCORE,
34    LOWER_CAMEL,
35    UPPER_CAMEL,
36    UPPER_UNDERSCORE,
37];
38
39/// Naming styles supported by this crate.
40///
41/// The conversion rules are intended for ASCII identifiers. Non-ASCII input is
42/// accepted on a best-effort basis, but its exact conversion behavior is not a
43/// stable contract.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum CaseStyle {
46    /// Hyphen separated lowercase words, such as `lower-hyphen`.
47    LowerHyphen,
48
49    /// Underscore separated lowercase words, such as `lower_underscore`.
50    LowerUnderscore,
51
52    /// Lower camel case words, such as `lowerCamel`.
53    LowerCamel,
54
55    /// Upper camel case words, such as `UpperCamel`.
56    UpperCamel,
57
58    /// Underscore separated uppercase words, such as `UPPER_UNDERSCORE`.
59    UpperUnderscore,
60}
61
62impl CaseStyle {
63    /// XML hyphenated variable naming style, such as `lower-hyphen`.
64    pub const LOWER_HYPHEN: Self = Self::LowerHyphen;
65
66    /// C++/Python variable naming style, such as `lower_underscore`.
67    pub const LOWER_UNDERSCORE: Self = Self::LowerUnderscore;
68
69    /// Java variable naming style, such as `lowerCamel`.
70    pub const LOWER_CAMEL: Self = Self::LowerCamel;
71
72    /// Java and C++ class naming style, such as `UpperCamel`.
73    pub const UPPER_CAMEL: Self = Self::UpperCamel;
74
75    /// Java and C++ constant naming style, such as `UPPER_UNDERSCORE`.
76    pub const UPPER_UNDERSCORE: Self = Self::UpperUnderscore;
77
78    /// Returns all supported naming styles in the reference implementation
79    /// order.
80    ///
81    /// # Returns
82    ///
83    /// Returns a static slice containing all naming styles.
84    #[inline]
85    pub const fn values() -> &'static [Self; 5] {
86        &VALUES
87    }
88
89    /// Parses a naming style from its canonical name.
90    ///
91    /// The comparison is case-insensitive, and hyphen and underscore are
92    /// treated as equivalent separators.
93    ///
94    /// # Parameters
95    ///
96    /// * `name` - Naming style name to parse.
97    ///
98    /// # Returns
99    ///
100    /// Returns the parsed naming style, or `CaseStyleError` carrying the
101    /// original name when no style matches.
102    pub fn of(name: &str) -> Result<Self, CaseStyleError> {
103        let normalized_name = name.replace('_', "-").to_ascii_lowercase();
104        for style in Self::values() {
105            if style.name() == normalized_name {
106                return Ok(*style);
107            }
108        }
109        Err(CaseStyleError::new(name))
110    }
111
112    /// Returns the canonical name of this naming style.
113    ///
114    /// # Returns
115    ///
116    /// Returns the lower-hyphen style name used by the JavaScript reference
117    /// implementation.
118    #[inline]
119    pub const fn name(self) -> &'static str {
120        match self {
121            Self::LowerHyphen => "lower-hyphen",
122            Self::LowerUnderscore => "lower-underscore",
123            Self::LowerCamel => "lower-camel",
124            Self::UpperCamel => "upper-camel",
125            Self::UpperUnderscore => "upper-underscore",
126        }
127    }
128
129    /// Returns the word separator inserted by this naming style.
130    ///
131    /// # Returns
132    ///
133    /// Returns `"-"` for lower hyphen, `"_"` for underscore styles, and `""`
134    /// for camel case styles.
135    #[inline]
136    pub const fn word_separator(self) -> &'static str {
137        match self {
138            Self::LowerHyphen => "-",
139            Self::LowerUnderscore | Self::UpperUnderscore => "_",
140            Self::LowerCamel | Self::UpperCamel => "",
141        }
142    }
143
144    /// Converts a string from this style to the target style.
145    ///
146    /// # Parameters
147    ///
148    /// * `target` - Target naming style.
149    /// * `value` - Source string. It should be an ASCII identifier in this
150    ///   style; if it is not, conversion is best-effort.
151    ///
152    /// # Returns
153    ///
154    /// Returns the converted string. Empty input is returned as an empty
155    /// string.
156    pub fn to(self, target: Self, value: &str) -> String {
157        if value.is_empty() || target == self {
158            return value.to_string();
159        }
160        if let Some(converted) = self.quick_convert(target, value) {
161            return converted;
162        }
163        self.convert_by_words(target, value)
164    }
165
166    /// Tests whether a string strictly matches this naming style.
167    ///
168    /// # Parameters
169    ///
170    /// * `value` - String to test.
171    ///
172    /// # Returns
173    ///
174    /// Returns `true` if `value` is non-empty and follows this style's ASCII
175    /// identifier rules; otherwise returns `false`.
176    pub fn matches(self, value: &str) -> bool {
177        if value.is_empty() {
178            return false;
179        }
180        match self {
181            Self::LowerHyphen => {
182                matches_separated(value, b'-', is_ascii_lower_or_digit)
183            }
184            Self::LowerUnderscore => {
185                matches_separated(value, b'_', is_ascii_lower_or_digit)
186            }
187            Self::LowerCamel => matches_camel(value, is_ascii_lower),
188            Self::UpperCamel => matches_camel(value, is_ascii_upper),
189            Self::UpperUnderscore => {
190                matches_separated(value, b'_', is_ascii_upper_or_digit)
191            }
192        }
193    }
194
195    /// Performs optimized direct conversions for separator-only style pairs.
196    ///
197    /// # Parameters
198    ///
199    /// * `target` - Target naming style.
200    /// * `value` - Source string to convert.
201    ///
202    /// # Returns
203    ///
204    /// Returns `Some` converted string for optimized pairs, or `None` when the
205    /// generic word conversion should be used.
206    fn quick_convert(self, target: Self, value: &str) -> Option<String> {
207        match (self, target) {
208            (Self::LowerHyphen, Self::LowerUnderscore) => {
209                Some(value.replace('-', "_"))
210            }
211            (Self::LowerHyphen, Self::UpperUnderscore) => {
212                Some(value.replace('-', "_").to_ascii_uppercase())
213            }
214            (Self::LowerUnderscore, Self::LowerHyphen) => {
215                Some(value.replace('_', "-"))
216            }
217            (Self::LowerUnderscore, Self::UpperUnderscore) => {
218                Some(value.to_ascii_uppercase())
219            }
220            (Self::UpperUnderscore, Self::LowerHyphen) => {
221                Some(value.replace('_', "-").to_ascii_lowercase())
222            }
223            (Self::UpperUnderscore, Self::LowerUnderscore) => {
224                Some(value.to_ascii_lowercase())
225            }
226            _ => None,
227        }
228    }
229
230    /// Converts a string by splitting it into source words and normalizing
231    /// them.
232    ///
233    /// # Parameters
234    ///
235    /// * `target` - Target naming style.
236    /// * `value` - Source string to convert.
237    ///
238    /// # Returns
239    ///
240    /// Returns a best-effort converted string.
241    fn convert_by_words(self, target: Self, value: &str) -> String {
242        let mut out = String::with_capacity(
243            value.len() + 4 * target.word_separator().len(),
244        );
245        let mut word_start = 0;
246        let mut search_start = 0;
247        let mut has_boundary = false;
248        while let Some(boundary) = self.find_boundary(value, search_start) {
249            if word_start == boundary {
250                search_start = boundary + self.word_separator().len().max(1);
251                word_start = search_start.min(value.len());
252                continue;
253            }
254            let word = &value[word_start..boundary];
255            if word_start == 0 {
256                out.push_str(&target.normalize_first_word(word));
257            } else {
258                out.push_str(&target.normalize_word(word));
259            }
260            out.push_str(target.word_separator());
261            has_boundary = true;
262            word_start = boundary + self.word_separator().len();
263            search_start = boundary + self.word_separator().len().max(1);
264        }
265        if !has_boundary {
266            target.normalize_first_word(value)
267        } else {
268            let word = &value[word_start..];
269            out.push_str(&target.normalize_word(word));
270            out
271        }
272    }
273
274    /// Finds the next source word boundary in `value`.
275    ///
276    /// # Parameters
277    ///
278    /// * `value` - Source string to search.
279    /// * `start` - Byte index where the search starts.
280    ///
281    /// # Returns
282    ///
283    /// Returns the byte index of the next boundary, or `None` when no boundary
284    /// exists after `start`.
285    fn find_boundary(self, value: &str, start: usize) -> Option<usize> {
286        match self {
287            Self::LowerHyphen => find_first_byte(value, start, b'-'),
288            Self::LowerUnderscore | Self::UpperUnderscore => {
289                find_first_byte(value, start, b'_')
290            }
291            Self::LowerCamel | Self::UpperCamel => {
292                find_first_camel_case_boundary(value, start)
293            }
294        }
295    }
296
297    /// Normalizes a non-first word for this naming style.
298    ///
299    /// # Parameters
300    ///
301    /// * `word` - Source word to normalize.
302    ///
303    /// # Returns
304    ///
305    /// Returns the word normalized for this style.
306    fn normalize_word(self, word: &str) -> String {
307        match self {
308            Self::LowerHyphen | Self::LowerUnderscore => {
309                word.to_ascii_lowercase()
310            }
311            Self::LowerCamel | Self::UpperCamel => {
312                first_char_only_to_upper(word)
313            }
314            Self::UpperUnderscore => word.to_ascii_uppercase(),
315        }
316    }
317
318    /// Normalizes the first word for this naming style.
319    ///
320    /// # Parameters
321    ///
322    /// * `word` - First source word to normalize.
323    ///
324    /// # Returns
325    ///
326    /// Returns the first word normalized for this style.
327    fn normalize_first_word(self, word: &str) -> String {
328        match self {
329            Self::LowerCamel => word.to_ascii_lowercase(),
330            _ => self.normalize_word(word),
331        }
332    }
333}
334
335impl FromStr for CaseStyle {
336    type Err = CaseStyleError;
337
338    /// Parses a naming style from a string.
339    ///
340    /// # Parameters
341    ///
342    /// * `s` - Naming style name to parse.
343    ///
344    /// # Returns
345    ///
346    /// Returns the parsed style, or `CaseStyleError` when `s` is unknown.
347    #[inline]
348    fn from_str(s: &str) -> Result<Self, Self::Err> {
349        Self::of(s)
350    }
351}
352
353/// Finds the first occurrence of a byte from a start index.
354///
355/// # Parameters
356///
357/// * `value` - String to search.
358/// * `start` - Byte index where the search starts.
359/// * `needle` - ASCII byte to find.
360///
361/// # Returns
362///
363/// Returns the byte index of `needle`, or `None` if it is not found.
364fn find_first_byte(value: &str, start: usize, needle: u8) -> Option<usize> {
365    value
366        .as_bytes()
367        .iter()
368        .enumerate()
369        .skip(start)
370        .find_map(|(index, byte)| (*byte == needle).then_some(index))
371}
372
373/// Finds the next CamelCase word boundary.
374///
375/// # Parameters
376///
377/// * `value` - String to search.
378/// * `start` - Byte index where the search starts.
379///
380/// # Returns
381///
382/// Returns the byte index of the next CamelCase boundary, or `None` if no
383/// boundary exists.
384fn find_first_camel_case_boundary(value: &str, start: usize) -> Option<usize> {
385    let start = start.max(1);
386    value
387        .as_bytes()
388        .iter()
389        .enumerate()
390        .skip(start)
391        .find_map(|(index, _)| {
392            is_camel_case_word_boundary(value, index).then_some(index)
393        })
394}
395
396/// Tests whether an index is a CamelCase word boundary.
397///
398/// # Parameters
399///
400/// * `value` - String to test.
401/// * `index` - Byte index to examine.
402///
403/// # Returns
404///
405/// Returns `true` if `index` is a boundary according to the JavaScript
406/// reference finite-state rules; otherwise returns `false`.
407fn is_camel_case_word_boundary(value: &str, index: usize) -> bool {
408    let bytes = value.as_bytes();
409    if index == 0 || index >= bytes.len() {
410        return false;
411    }
412    let current_type = char_type(bytes[index]);
413    if current_type == CharType::Other {
414        return false;
415    }
416    let previous_type = char_type(bytes[index - 1]);
417    match previous_type {
418        CharType::Lower => {
419            current_type == CharType::Upper || current_type == CharType::Digit
420        }
421        CharType::Upper => {
422            if current_type == CharType::Lower {
423                false
424            } else if current_type == CharType::Digit {
425                true
426            } else if current_type == CharType::Upper {
427                let next = bytes
428                    .get(index + 1)
429                    .copied()
430                    .map(char_type)
431                    .unwrap_or(CharType::Other);
432                next == CharType::Lower
433            } else {
434                false
435            }
436        }
437        CharType::Digit => current_type == CharType::Upper,
438        CharType::Other => false,
439    }
440}
441
442/// ASCII character classes used by CamelCase boundary detection.
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444enum CharType {
445    /// ASCII uppercase letter.
446    Upper,
447
448    /// ASCII lowercase letter.
449    Lower,
450
451    /// ASCII decimal digit.
452    Digit,
453
454    /// Any other byte.
455    Other,
456}
457
458/// Classifies an ASCII byte.
459///
460/// # Parameters
461///
462/// * `byte` - Byte to classify.
463///
464/// # Returns
465///
466/// Returns the character class used by the boundary detector.
467fn char_type(byte: u8) -> CharType {
468    if is_ascii_upper(byte) {
469        CharType::Upper
470    } else if is_ascii_lower(byte) {
471        CharType::Lower
472    } else if is_ascii_digit(byte) {
473        CharType::Digit
474    } else {
475        CharType::Other
476    }
477}
478
479/// Converts a word to upper camel capitalization.
480///
481/// # Parameters
482///
483/// * `word` - Source word to normalize.
484///
485/// # Returns
486///
487/// Returns the word with its first ASCII character uppercased and remaining
488/// ASCII characters lowercased.
489fn first_char_only_to_upper(word: &str) -> String {
490    let mut chars = word.chars();
491    let Some(first) = chars.next() else {
492        return String::new();
493    };
494    let mut out = String::with_capacity(word.len());
495    out.push(first.to_ascii_uppercase());
496    out.push_str(&chars.as_str().to_ascii_lowercase());
497    out
498}
499
500/// Tests whether a separated style string matches all separator rules.
501///
502/// # Parameters
503///
504/// * `value` - String to test.
505/// * `separator` - Required ASCII separator byte.
506/// * `is_word_byte` - Predicate for valid non-separator bytes.
507///
508/// # Returns
509///
510/// Returns `true` if `value` has no leading, trailing, or repeated separators
511/// and every word byte satisfies `is_word_byte`.
512fn matches_separated(
513    value: &str,
514    separator: u8,
515    is_word_byte: fn(u8) -> bool,
516) -> bool {
517    let bytes = value.as_bytes();
518    if bytes.first() == Some(&separator) || bytes.last() == Some(&separator) {
519        return false;
520    }
521    let mut last_is_separator = false;
522    for byte in bytes {
523        if *byte == separator {
524            if last_is_separator {
525                return false;
526            }
527            last_is_separator = true;
528        } else if is_word_byte(*byte) {
529            last_is_separator = false;
530        } else {
531            return false;
532        }
533    }
534    true
535}
536
537/// Tests whether a camel style string matches the first character rule.
538///
539/// # Parameters
540///
541/// * `value` - String to test.
542/// * `is_valid_first` - Predicate for the required first byte.
543///
544/// # Returns
545///
546/// Returns `true` if the first byte satisfies `is_valid_first` and all
547/// following bytes are ASCII letters or digits.
548fn matches_camel(value: &str, is_valid_first: fn(u8) -> bool) -> bool {
549    let bytes = value.as_bytes();
550    if !is_valid_first(bytes[0]) {
551        return false;
552    }
553    bytes.iter().skip(1).all(|byte| is_ascii_alnum(*byte))
554}
555
556/// Tests whether a byte is an ASCII lowercase letter.
557///
558/// # Parameters
559///
560/// * `byte` - Byte to test.
561///
562/// # Returns
563///
564/// Returns `true` for `a` through `z`.
565#[inline]
566fn is_ascii_lower(byte: u8) -> bool {
567    byte.is_ascii_lowercase()
568}
569
570/// Tests whether a byte is an ASCII uppercase letter.
571///
572/// # Parameters
573///
574/// * `byte` - Byte to test.
575///
576/// # Returns
577///
578/// Returns `true` for `A` through `Z`.
579#[inline]
580fn is_ascii_upper(byte: u8) -> bool {
581    byte.is_ascii_uppercase()
582}
583
584/// Tests whether a byte is an ASCII decimal digit.
585///
586/// # Parameters
587///
588/// * `byte` - Byte to test.
589///
590/// # Returns
591///
592/// Returns `true` for `0` through `9`.
593#[inline]
594fn is_ascii_digit(byte: u8) -> bool {
595    byte.is_ascii_digit()
596}
597
598/// Tests whether a byte is an ASCII letter or digit.
599///
600/// # Parameters
601///
602/// * `byte` - Byte to test.
603///
604/// # Returns
605///
606/// Returns `true` for ASCII letters or decimal digits.
607#[inline]
608fn is_ascii_alnum(byte: u8) -> bool {
609    byte.is_ascii_alphanumeric()
610}
611
612/// Tests whether a byte is an ASCII lowercase letter or digit.
613///
614/// # Parameters
615///
616/// * `byte` - Byte to test.
617///
618/// # Returns
619///
620/// Returns `true` for lowercase ASCII letters or decimal digits.
621#[inline]
622fn is_ascii_lower_or_digit(byte: u8) -> bool {
623    is_ascii_lower(byte) || is_ascii_digit(byte)
624}
625
626/// Tests whether a byte is an ASCII uppercase letter or digit.
627///
628/// # Parameters
629///
630/// * `byte` - Byte to test.
631///
632/// # Returns
633///
634/// Returns `true` for uppercase ASCII letters or decimal digits.
635#[inline]
636fn is_ascii_upper_or_digit(byte: u8) -> bool {
637    is_ascii_upper(byte) || is_ascii_digit(byte)
638}