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