Skip to content

Commit b9e8a9c

Browse files
authored
Merge pull request #22 from lupodevelop/version/2.1.0
Add mixed-case check and case converters
2 parents 62ef90a + 09bd453 commit b9e8a9c

8 files changed

Lines changed: 232 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
All notable changes to this project are documented in this file.
44

5+
## [2.1.0] - 2026-03-31
6+
7+
### Added
8+
9+
- `is_mixed_case`: validation function to check if a string contains both uppercase and lowercase characters.
10+
- Dedicated case converters: `camel_to_snake`, `pascal_to_snake`, `snake_to_camel`, `snake_to_pascal` that don't aggressively strip non-alphanumeric characters.
11+
12+
### Changed
13+
14+
- Enhanced `words` to handle additional Unicode whitespace characters (non-breaking space, en-space, em-space, thin space, zero-width space, ideographic space).
15+
- Optimized string concatenation in `repeat_str` by replacing basic concatenation with `gleam/string_tree` for better performance on large string repetitions.
16+
17+
---
18+
519
## [2.0.1] - 2026-02-28
620

721
### Fixed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ pub fn main() {
9191
| `is_uppercase(text)` | `"HELLO123"` | `True` |
9292
| `is_lowercase(text)` | `"hello_world"` | `True` |
9393
| `is_title_case(text)` | `"Hello World"` | `True` |
94+
| `is_mixed_case(text)` | `"helloWorld"` | `True` |
9495

9596
### ✂️ Grapheme Extraction
9697

@@ -239,6 +240,10 @@ str.to_camel_case("hello world") // → "helloWorld"
239240
str.to_pascal_case("hello world") // → "HelloWorld"
240241
str.to_kebab_case("Hello World") // → "hello-world"
241242
str.to_title_case("hello world") // → "Hello World"
243+
str.camel_to_snake("camelCase") // → "camel_case"
244+
str.snake_to_camel("snake_case") // → "snakeCase"
245+
str.pascal_to_snake("PascalCase") // → "pascal_case"
246+
str.snake_to_pascal("snake_case") // → "SnakeCase"
242247
```
243248

244249
### ASCII Folding (Deburr)

gleam.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name = "str"
2-
version = "2.0.1"
2+
version = "2.1.0"
33

44
# Project metadata (fill or replace placeholders before publishing)
55
description = "Unicode-aware string utilities for Gleam: grapheme-safe operations, pragmatic ASCII transliteration, and slug generation."

src/str.gleam

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,11 @@ pub fn is_lowercase(text: String) -> Bool {
430430
core.is_lowercase(text)
431431
}
432432

433+
/// Checks if text contains both uppercase and lowercase characters.
434+
pub fn is_mixed_case(text: String) -> Bool {
435+
core.is_mixed_case(text)
436+
}
437+
433438
/// Checks if text is in Title Case format.
434439
pub fn is_title_case(text: String) -> Bool {
435440
core.is_title_case(text)
@@ -787,6 +792,26 @@ pub fn to_title_case(text: String) -> String {
787792
extra.to_title_case(text)
788793
}
789794

795+
/// Converts camelCase or PascalCase to snake_case.
796+
pub fn camel_to_snake(text: String) -> String {
797+
extra.camel_to_snake(text)
798+
}
799+
800+
/// Alias for camel_to_snake.
801+
pub fn pascal_to_snake(text: String) -> String {
802+
extra.pascal_to_snake(text)
803+
}
804+
805+
/// Converts snake_case to camelCase.
806+
pub fn snake_to_camel(text: String) -> String {
807+
extra.snake_to_camel(text)
808+
}
809+
810+
/// Converts snake_case to PascalCase.
811+
pub fn snake_to_pascal(text: String) -> String {
812+
extra.snake_to_pascal(text)
813+
}
814+
790815
// ============================================================================
791816
// GRAPHEME TOKENIZATION (from str/tokenize)
792817
// ============================================================================

src/str/internal/core.gleam

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import gleam/dict
1313
import gleam/int
1414
import gleam/list
1515
import gleam/string
16+
import gleam/string_tree
1617
import houdini
1718
import odysseus
1819
import str/config
@@ -154,6 +155,12 @@ pub fn words(text: String) -> List(String) {
154155
|> string.replace("\n", " ")
155156
|> string.replace("\r", " ")
156157
|> string.replace("\t", " ")
158+
|> string.replace("\u{00A0}", " ")
159+
|> string.replace("\u{2002}", " ")
160+
|> string.replace("\u{2003}", " ")
161+
|> string.replace("\u{2009}", " ")
162+
|> string.replace("\u{200B}", " ")
163+
|> string.replace("\u{3000}", " ")
157164

158165
normalized
159166
|> string.split(" ")
@@ -176,13 +183,20 @@ pub fn is_blank(text: String) -> Bool {
176183
///
177184
/// Internal helper for padding operations. Returns empty string if n <= 0.
178185
fn repeat_str(s: String, n: Int) -> String {
179-
repeat_str_loop(s, n, "")
186+
case n <= 0 {
187+
True -> ""
188+
False -> repeat_str_loop(s, n, string_tree.new()) |> string_tree.to_string
189+
}
180190
}
181191

182-
fn repeat_str_loop(s: String, n: Int, acc: String) -> String {
192+
fn repeat_str_loop(
193+
s: String,
194+
n: Int,
195+
acc: string_tree.StringTree,
196+
) -> string_tree.StringTree {
183197
case n <= 0 {
184198
True -> acc
185-
False -> repeat_str_loop(s, n - 1, acc <> s)
199+
False -> repeat_str_loop(s, n - 1, string_tree.append(acc, s))
186200
}
187201
}
188202

@@ -1646,6 +1660,24 @@ pub fn is_lowercase(text: String) -> Bool {
16461660
}
16471661
}
16481662

1663+
/// Checks if text contains both uppercase and lowercase characters.
1664+
/// Non-cased characters are ignored.
1665+
/// Returns False for empty strings or strings with no cased characters.
1666+
///
1667+
/// is_mixed_case("Hello") -> True
1668+
/// is_mixed_case("hello") -> False
1669+
/// is_mixed_case("HELLO") -> False
1670+
/// is_mixed_case("Hello123") -> True
1671+
/// is_mixed_case("123") -> False
1672+
/// is_mixed_case("") -> False
1673+
///
1674+
pub fn is_mixed_case(text: String) -> Bool {
1675+
let chars = string.to_graphemes(text)
1676+
let has_upper = list.any(chars, is_grapheme_uppercase)
1677+
let has_lower = list.any(chars, is_grapheme_lowercase)
1678+
has_upper && has_lower
1679+
}
1680+
16491681
/// Checks if text is in Title Case (first letter of each word is uppercase).
16501682
/// Non-alphabetic characters are ignored. Empty strings return False.
16511683
///

src/str/internal/extra.gleam

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/str/internal/translit.gleam

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ pub fn replacements() -> List(#(String, String)) {
157157
// Czech/Slovak extras
158158
#("Ŕ", "R"),
159159
#("ŕ", "r"),
160+
// Common Symbols
161+
#("€", "EUR"),
162+
#("£", "GBP"),
163+
#("¥", "JPY"),
164+
#("©", "(c)"),
165+
#("®", "(r)"),
166+
#("™", "tm"),
167+
#("…", "..."),
160168
]
161169
}
162170

test/str_new_converters_test.gleam

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import gleeunit/should
2+
import str
3+
4+
pub fn is_mixed_case_test() {
5+
str.is_mixed_case("Hello") |> should.be_true
6+
str.is_mixed_case("hello") |> should.be_false
7+
str.is_mixed_case("HELLO") |> should.be_false
8+
str.is_mixed_case("Hello123") |> should.be_true
9+
str.is_mixed_case("123") |> should.be_false
10+
str.is_mixed_case("") |> should.be_false
11+
}
12+
13+
pub fn camel_to_snake_test() {
14+
str.camel_to_snake("camelCase") |> should.equal("camel_case")
15+
str.camel_to_snake("XMLHttpRequest") |> should.equal("xml_http_request")
16+
str.camel_to_snake("simple") |> should.equal("simple")
17+
str.camel_to_snake("Already_Snake") |> should.equal("already_snake")
18+
}
19+
20+
pub fn pascal_to_snake_test() {
21+
str.pascal_to_snake("PascalCase") |> should.equal("pascal_case")
22+
str.pascal_to_snake("XMLHttpRequest") |> should.equal("xml_http_request")
23+
}
24+
25+
pub fn snake_to_camel_test() {
26+
str.snake_to_camel("snake_case_name") |> should.equal("snakeCaseName")
27+
str.snake_to_camel("simple") |> should.equal("simple")
28+
// Testing numbers and acroynms
29+
str.snake_to_camel("xml_http_request") |> should.equal("xmlHttpRequest")
30+
}
31+
32+
pub fn snake_to_pascal_test() {
33+
str.snake_to_pascal("snake_case_name") |> should.equal("SnakeCaseName")
34+
str.snake_to_pascal("simple") |> should.equal("Simple")
35+
}

0 commit comments

Comments
 (0)