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}