@@ -394,6 +394,115 @@ pub fn to_title_case(s: String) -> String {
394394 } )
395395 string . join ( capitalized , " " )
396396}
397+
398+ // ----------------------------------------------------------------------------
399+ // Dedicated, Non-Destructive Case Converters
400+ // ----------------------------------------------------------------------------
401+
402+ fn is_upper_char ( g : String ) -> Bool {
403+ case string . to_utf_codepoints ( g ) {
404+ [ cp ] -> {
405+ let code = string . utf_codepoint_to_int ( cp )
406+ code >= 0x41 && code <= 0x5A
407+ }
408+ _ -> False
409+ }
410+ }
411+
412+ fn is_lower_char ( g : String ) -> Bool {
413+ case string . to_utf_codepoints ( g ) {
414+ [ cp ] -> {
415+ let code = string . utf_codepoint_to_int ( cp )
416+ code >= 0x61 && code <= 0x7A
417+ }
418+ _ -> False
419+ }
420+ }
421+
422+ fn camel_to_snake_loop (
423+ chars : List ( String ) ,
424+ acc : String ,
425+ prev_char : String ,
426+ ) -> String {
427+ case chars {
428+ [ ] -> acc
429+ [ c , .. rest ] -> {
430+ let c_is_upper = is_upper_char ( c )
431+ let prev_is_lower = is_lower_char ( prev_char )
432+ let next_is_lower = case rest {
433+ [ n , .. ] -> is_lower_char ( n )
434+ [ ] -> False
435+ }
436+
437+ let insert_underscore =
438+ { prev_is_lower && c_is_upper }
439+ || { is_upper_char ( prev_char ) && c_is_upper && next_is_lower }
440+
441+ let new_acc = case
442+ insert_underscore && acc != "" && ! string . ends_with ( acc , "_" )
443+ {
444+ True -> acc <> "_" <> string . lowercase ( c )
445+ False -> acc <> string . lowercase ( c )
446+ }
447+ camel_to_snake_loop ( rest , new_acc , c )
448+ }
449+ }
450+ }
451+
452+ /// Converts camelCase or PascalCase to snake_case without aggressively stripping characters.
453+ ///
454+ /// camel_to_snake("camelCase") -> "camel_case"
455+ /// camel_to_snake("XMLHttpRequest") -> "xml_http_request"
456+ ///
457+ pub fn camel_to_snake ( s : String ) -> String {
458+ camel_to_snake_loop ( string . to_graphemes ( s ) , "" , "" )
459+ }
460+
461+ /// Alias for camel_to_snake.
462+ pub fn pascal_to_snake ( s : String ) -> String {
463+ camel_to_snake ( s )
464+ }
465+
466+ /// Converts snake_case to camelCase without aggressively stripping characters.
467+ ///
468+ /// snake_to_camel("snake_case_name") -> "snakeCaseName"
469+ ///
470+ pub fn snake_to_camel ( s : String ) -> String {
471+ let parts = string . split ( s , "_" )
472+ case parts {
473+ [ ] -> ""
474+ [ first , .. rest ] -> {
475+ let camel_rest =
476+ list . fold ( rest , "" , fn ( acc , part ) {
477+ case string . is_empty ( part ) {
478+ True -> acc
479+ False ->
480+ acc
481+ <> string . uppercase ( string . slice ( part , 0 , 1 ) )
482+ <> string . slice ( part , 1 , string . length ( part ) - 1 )
483+ }
484+ } )
485+ string . lowercase ( first ) <> camel_rest
486+ }
487+ }
488+ }
489+
490+ /// Converts snake_case to PascalCase without aggressively stripping characters.
491+ ///
492+ /// snake_to_pascal("snake_case_name") -> "SnakeCaseName"
493+ ///
494+ pub fn snake_to_pascal ( s : String ) -> String {
495+ let parts = string . split ( s , "_" )
496+ list . fold ( parts , "" , fn ( acc , part ) {
497+ case string . is_empty ( part ) {
498+ True -> acc
499+ False ->
500+ acc
501+ <> string . uppercase ( string . slice ( part , 0 , 1 ) )
502+ <> string . slice ( part , 1 , string . length ( part ) - 1 )
503+ }
504+ } )
505+ }
397506// Note: normalizer helpers (NFC/NFD/NFKC/NFKD) are intentionally not
398507// exported by the `str` library to avoid introducing an OTP dependency.
399508// If you need to use OTP normalization, define a small helper in your
0 commit comments