Skip to content

Commit 873c42b

Browse files
Match CPython's title() exactly
1 parent 067072e commit 873c42b

3 files changed

Lines changed: 61 additions & 34 deletions

File tree

‎Lib/test/string_tests.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,8 +1134,9 @@ def test_capitalize_nonascii(self):
11341134
self.checkequal('\u2160\u2171\u2172',
11351135
'\u2170\u2171\u2172', 'capitalize')
11361136
# check with Ll chars with no upper - nothing changes here
1137-
self.checkequal('\u019b\u1d00\u1d86\u0221\u1fb7',
1138-
'\u019b\u1d00\u1d86\u0221\u1fb7', 'capitalize')
1137+
# This test doesn't exist anymore in Python 3.15 and fails on 3.15
1138+
# self.checkequal('\u019b\u1d00\u1d86\u0221\u1fb7',
1139+
# '\u019b\u1d00\u1d86\u0221\u1fb7', 'capitalize')
11391140

11401141
def test_startswith(self):
11411142
self.checkequal(True, 'hello', 'startswith', 'he')

‎Lib/test/test_str.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -955,7 +955,6 @@ def test_capitalize(self):
955955
self.assertEqual('finnish'.capitalize(), 'Finnish')
956956
self.assertEqual('A\u0345\u03a3'.capitalize(), 'A\u0345\u03c2')
957957

958-
@unittest.expectedFailure # TODO: RUSTPYTHON; ? ^
959958
def test_title(self):
960959
super().test_title()
961960
self.assertEqual('\U0001044F'.title(), '\U00010427')

‎crates/vm/src/builtins/str.rs‎

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,9 @@ impl PyStr {
759759
let mut chars = s.char_indices();
760760
let mut out = VecFmtWriter(Vec::with_capacity(s.len()));
761761
titlecase_first(s, &mut chars, &mut out);
762-
lowercase_and_sigma(s, &mut chars, &mut out);
762+
for (i, ch) in chars {
763+
lowercase_or_sigma(ch, s, i, &mut out);
764+
}
763765
unsafe { Wtf8Buf::from_bytes_unchecked(out.0) }
764766
}
765767
PyKindStr::Wtf8(s) => {
@@ -770,15 +772,18 @@ impl PyStr {
770772
let s = first.valid();
771773
let mut chars = s.char_indices();
772774
titlecase_first(s, &mut chars, &mut out);
773-
lowercase_and_sigma(s, &mut chars, &mut out);
775+
for (i, ch) in chars {
776+
lowercase_or_sigma(ch, s, i, &mut out);
777+
}
774778
out.0.extend(first.invalid());
775779
}
776780
// This loop is only hit if the WTF-8 buffer contains invalid Unicode. Otherwise,
777781
// everything is handled above without chunking.
778782
for chunk in chunks {
779783
let s = chunk.valid();
780-
let mut chars = s.char_indices();
781-
lowercase_and_sigma(s, &mut chars, &mut out);
784+
for (i, ch) in s.char_indices() {
785+
lowercase_or_sigma(ch, s, i, &mut out);
786+
}
782787
out.0.extend(chunk.invalid());
783788
}
784789

@@ -1053,28 +1058,22 @@ impl PyStr {
10531058
PyKindStr::Ascii(_) => unsafe {
10541059
Wtf8Buf::from_bytes_unchecked(crate::bytes_inner::title_ascii(self.as_bytes()))
10551060
},
1056-
PyKindStr::Utf8(s) => TitlecaseMapper::new()
1057-
.titlecase_segment_to_string(s, &LanguageIdentifier::UNKNOWN, Default::default())
1058-
.to_string()
1059-
.into(),
1061+
PyKindStr::Utf8(s) => {
1062+
let mut out = VecFmtWriter(Vec::with_capacity(s.len()));
1063+
titlecase_string(s, &mut out);
1064+
// SAFETY: `s` is valid UTF-8 and titlecase_string only works on Unicode.
1065+
unsafe { Wtf8Buf::from_bytes_unchecked(out.0) }
1066+
}
10601067
PyKindStr::Wtf8(s) => {
1061-
let mut buf = VecFmtWriter(Vec::with_capacity(s.len()));
1062-
let mapper = TitlecaseMapper::new();
1068+
let mut out = VecFmtWriter(Vec::with_capacity(s.len()));
10631069
for chunk in s.as_bytes().utf8_chunks() {
1064-
mapper
1065-
.titlecase_segment(
1066-
chunk.valid(),
1067-
&LanguageIdentifier::UNKNOWN,
1068-
Default::default(),
1069-
)
1070-
.write_to(&mut buf)
1071-
.expect("Writing to an in-memory buffer cannot fail.");
1072-
buf.0.extend(chunk.invalid());
1070+
titlecase_string(chunk.valid(), &mut out);
1071+
out.0.extend(chunk.invalid());
10731072
}
10741073
// SAFETY:
10751074
// * `s` is valid WTF-8; surrogate bytes were appended without processing.
10761075
// * TitlecaseMapper produces valid UTF-8.
1077-
unsafe { Wtf8Buf::from_bytes_unchecked(buf.0) }
1076+
unsafe { Wtf8Buf::from_bytes_unchecked(out.0) }
10781077
}
10791078
}
10801079
}
@@ -1549,6 +1548,11 @@ impl PyStr {
15491548
}
15501549
}
15511550

1551+
/// Title case first char if it is cased or write as is.
1552+
///
1553+
/// This matches CPython's behavior:
1554+
/// "123abc" -> "123abc"
1555+
/// "abc" -> "Abc"
15521556
fn titlecase_first(s: &str, chars: &mut core::str::CharIndices<'_>, out: &mut VecFmtWriter) {
15531557
if let Some((first_pos, first_ch)) = chars.next() {
15541558
let first = &s[..first_pos + first_ch.len_utf8()];
@@ -1559,20 +1563,43 @@ fn titlecase_first(s: &str, chars: &mut core::str::CharIndices<'_>, out: &mut Ve
15591563
}
15601564
}
15611565

1562-
fn lowercase_and_sigma(s: &str, chars: &mut core::str::CharIndices<'_>, out: &mut VecFmtWriter) {
1566+
/// Title case a string following CPython conventions.
1567+
///
1568+
/// CPython title cases each char in a segment. A "segment" is split by case ignorable characters
1569+
/// rather than whitespace.
1570+
/// "123abc" -> "123Abc"
1571+
/// "123abc456def" -> "123Abc456Def"
1572+
/// "123 abc" -> "123 Abc"
1573+
fn titlecase_string(s: &str, out: &mut VecFmtWriter) {
1574+
let mut previous_is_cased = false;
1575+
let mapper = TitlecaseMapper::new();
1576+
for (i, ch) in s.char_indices() {
1577+
if previous_is_cased {
1578+
lowercase_or_sigma(ch, s, i, out);
1579+
} else {
1580+
let s = &s[i..i + ch.len_utf8()];
1581+
mapper
1582+
.titlecase_segment(s, &LanguageIdentifier::UNKNOWN, Default::default())
1583+
.write_to(out)
1584+
.expect("Writing to an in-memory buffer cannot fail.");
1585+
}
1586+
1587+
previous_is_cased = Cased::for_char(ch);
1588+
}
1589+
}
1590+
1591+
fn lowercase_or_sigma(ch: char, s: &str, i: usize, out: &mut VecFmtWriter) {
15631592
let sigma = 'Σ';
1564-
for (i, ch) in chars {
1565-
if ch == sigma {
1566-
let sigma_cased = handle_capital_sigma(s, i);
1593+
if ch == sigma {
1594+
let sigma_cased = handle_capital_sigma(s, i);
1595+
let mut buf = [0u8; 4];
1596+
let s = sigma_cased.encode_utf8(&mut buf);
1597+
out.0.extend(s.as_bytes());
1598+
} else {
1599+
for ch in ch.to_lowercase() {
15671600
let mut buf = [0u8; 4];
1568-
let s = sigma_cased.encode_utf8(&mut buf);
1601+
let s = ch.encode_utf8(&mut buf);
15691602
out.0.extend(s.as_bytes());
1570-
} else {
1571-
for ch in ch.to_lowercase() {
1572-
let mut buf = [0u8; 4];
1573-
let s = ch.encode_utf8(&mut buf);
1574-
out.0.extend(s.as_bytes());
1575-
}
15761603
}
15771604
}
15781605
}

0 commit comments

Comments
 (0)