Skip to content

Commit 786e852

Browse files
committed
Add mixed-case check and case converters
Introduce is_mixed_case (core) and four dedicated case converters (camel_to_snake, pascal_to_snake, snake_to_camel, snake_to_pascal) exposed from src/str.gleam and implemented in internal modules. Improve words to normalize additional Unicode whitespace characters and optimize repeat_str by switching to gleam/string_tree for better performance on large repetitions. Extend transliteration table with common symbol mappings (€, £, ¥, ©, ®, ™, …). Add tests for the new converters, update README and CHANGELOG, and bump package version to 2.1.0.
1 parent 62ef90a commit 786e852

8 files changed

Lines changed: 213 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: 31 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,16 @@ 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(s: String, n: Int, acc: string_tree.StringTree) -> string_tree.StringTree {
183193
case n <= 0 {
184194
True -> acc
185-
False -> repeat_str_loop(s, n - 1, acc <> s)
195+
False -> repeat_str_loop(s, n - 1, string_tree.append(acc, s))
186196
}
187197
}
188198

@@ -1646,6 +1656,24 @@ pub fn is_lowercase(text: String) -> Bool {
16461656
}
16471657
}
16481658

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

src/str/internal/extra.gleam

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,100 @@ 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(chars: List(String), acc: String, prev_char: String) -> String {
423+
case chars {
424+
[] -> acc
425+
[c, ..rest] -> {
426+
let c_is_upper = is_upper_char(c)
427+
let prev_is_lower = is_lower_char(prev_char)
428+
let next_is_lower = case rest {
429+
[n, ..] -> is_lower_char(n)
430+
[] -> False
431+
}
432+
433+
let insert_underscore = {prev_is_lower && c_is_upper} || {is_upper_char(prev_char) && c_is_upper && next_is_lower}
434+
435+
let new_acc = case insert_underscore && acc != "" && !string.ends_with(acc, "_") {
436+
True -> acc <> "_" <> string.lowercase(c)
437+
False -> acc <> string.lowercase(c)
438+
}
439+
camel_to_snake_loop(rest, new_acc, c)
440+
}
441+
}
442+
}
443+
444+
/// Converts camelCase or PascalCase to snake_case without aggressively stripping characters.
445+
///
446+
/// camel_to_snake("camelCase") -> "camel_case"
447+
/// camel_to_snake("XMLHttpRequest") -> "xml_http_request"
448+
///
449+
pub fn camel_to_snake(s: String) -> String {
450+
camel_to_snake_loop(string.to_graphemes(s), "", "")
451+
}
452+
453+
/// Alias for camel_to_snake.
454+
pub fn pascal_to_snake(s: String) -> String {
455+
camel_to_snake(s)
456+
}
457+
458+
/// Converts snake_case to camelCase without aggressively stripping characters.
459+
///
460+
/// snake_to_camel("snake_case_name") -> "snakeCaseName"
461+
///
462+
pub fn snake_to_camel(s: String) -> String {
463+
let parts = string.split(s, "_")
464+
case parts {
465+
[] -> ""
466+
[first, ..rest] -> {
467+
let camel_rest = list.fold(rest, "", fn(acc, part) {
468+
case string.is_empty(part) {
469+
True -> acc
470+
False -> acc <> string.uppercase(string.slice(part, 0, 1)) <> string.slice(part, 1, string.length(part) - 1)
471+
}
472+
})
473+
string.lowercase(first) <> camel_rest
474+
}
475+
}
476+
}
477+
478+
/// Converts snake_case to PascalCase without aggressively stripping characters.
479+
///
480+
/// snake_to_pascal("snake_case_name") -> "SnakeCaseName"
481+
///
482+
pub fn snake_to_pascal(s: String) -> String {
483+
let parts = string.split(s, "_")
484+
list.fold(parts, "", fn(acc, part) {
485+
case string.is_empty(part) {
486+
True -> acc
487+
False -> acc <> string.uppercase(string.slice(part, 0, 1)) <> string.slice(part, 1, string.length(part) - 1)
488+
}
489+
})
490+
}
397491
// Note: normalizer helpers (NFC/NFD/NFKC/NFKD) are intentionally not
398492
// exported by the `str` library to avoid introducing an OTP dependency.
399493
// 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)