kohebi_core/casing.rs
1//! Changing the case of a string, which is six questions rather than one.
2//!
3//! `upper` and `lower` are the two that look easy, and even those are not a
4//! per-character mapping: `'ß'.upper()` is two characters and `'Σ'.lower()` is
5//! one of two characters depending on what is around it. The other four are
6//! each different again. `title` has to know where a word starts, `capitalize`
7//! uses a third mapping that is neither of the first two, `swapcase` picks per
8//! character, and `casefold` is a mapping of its own that disagrees with
9//! `lower` on a few hundred code points.
10//!
11//! The data all of that needs is in the `table` module next door, generated
12//! from the CPython whose answers we are matching. Rust's standard library has
13//! some of it and is not used, because it carries its own copy of the Unicode
14//! data and the two are not always the same release.
15//!
16//! ## The final sigma
17//!
18//! Greek writes a lowercase sigma as `ς` at the end of a word and `σ`
19//! everywhere else, and the uppercase is `Σ` either way, so lowercasing a
20//! sigma is the one decision here that is not a fact about the code point. The
21//! rule is that the final form is used when there is a cased character before
22//! the sigma and none after it, looking past case-ignorable characters in both
23//! directions.
24//!
25//! It matters that the scan reads the original string and not the output. In
26//! `'ΑΣΣ'` the first sigma is followed by a cased character and the second is
27//! not, so the answer is `'ασς'`, and a runtime that lowercased left to right
28//! while looking at what it had already written would get the first one wrong.
29//! Four of the six methods lowercase something, and all four take their
30//! context from the input.
31
32mod table;
33
34use table::{CASED, FOLD, IGNORABLE, LOWER, LOWERCASE, TITLE, TITLECASE, UPPER, UPPERCASE};
35
36use crate::ranges::among;
37
38/// `Σ`, the only code point whose lowercase depends on where it is.
39const CAPITAL_SIGMA: u32 = 0x03A3;
40/// `ς`, the form used at the end of a word.
41const FINAL_SIGMA: u32 = 0x03C2;
42/// `σ`, the form used everywhere else.
43const SMALL_SIGMA: u32 = 0x03C3;
44
45/// `str.upper`.
46///
47/// Context free, unlike its opposite, so this is the whole of it.
48#[must_use]
49pub fn upper(points: &[u32]) -> Vec<u32> {
50 let mut out = Vec::with_capacity(points.len());
51 for &cp in points {
52 map(&mut out, &UPPER, cp);
53 }
54 out
55}
56
57/// `str.lower`.
58#[must_use]
59pub fn lower(points: &[u32]) -> Vec<u32> {
60 let mut out = Vec::with_capacity(points.len());
61 for at in 0..points.len() {
62 lowered(&mut out, points, at);
63 }
64 out
65}
66
67/// `str.casefold`, which is for comparing rather than for displaying.
68///
69/// It has no final sigma rule, and does not want one: the point of folding is
70/// that `'ΑΣ'` and `'Ας'` come out the same, which they do because both fold
71/// to `'ασ'`. Lowercasing them would keep them apart.
72#[must_use]
73pub fn casefold(points: &[u32]) -> Vec<u32> {
74 let mut out = Vec::with_capacity(points.len());
75 for &cp in points {
76 map(&mut out, &FOLD, cp);
77 }
78 out
79}
80
81/// `str.swapcase`.
82///
83/// Not `upper` and `lower` applied to alternate halves of the alphabet. The
84/// test is per character and is the `Uppercase` and `Lowercase` properties, so
85/// a titlecase character such as `Dž` is neither and is left where it is.
86#[must_use]
87pub fn swapcase(points: &[u32]) -> Vec<u32> {
88 let mut out = Vec::with_capacity(points.len());
89 for (at, &cp) in points.iter().enumerate() {
90 if among(&UPPERCASE, cp) {
91 lowered(&mut out, points, at);
92 } else if among(&LOWERCASE, cp) {
93 map(&mut out, &UPPER, cp);
94 } else {
95 out.push(cp);
96 }
97 }
98 out
99}
100
101/// `str.title`.
102///
103/// A word starts after anything that is not cased, and the character that
104/// starts one gets the titlecase mapping rather than the uppercase one. Those
105/// differ for the digraphs: `'dž'.title()` is `'Dž'` and `'dž'.upper()` is `'DŽ'`.
106///
107/// The predicate for carrying on a word is `Cased` and emphatically not
108/// `isalpha`, which disagrees with it in both directions. `'あa'.title()` is
109/// `'あA'` because hiragana is alphabetic and not cased, and `'ⅰa'.title()` is
110/// `'Ⅰa'` because a lowercase roman numeral is cased and not alphabetic.
111#[must_use]
112pub fn title(points: &[u32]) -> Vec<u32> {
113 let mut out = Vec::with_capacity(points.len());
114 let mut inside = false;
115 for (at, &cp) in points.iter().enumerate() {
116 if inside {
117 lowered(&mut out, points, at);
118 } else {
119 map(&mut out, &TITLE, cp);
120 }
121 // The original code point decides, not what was just written for it.
122 inside = among(&CASED, cp);
123 }
124 out
125}
126
127/// `str.capitalize`, which is `title` that stops looking for words after the
128/// first character.
129///
130/// The first character gets the titlecase mapping, which is worth saying
131/// because the name suggests the uppercase one and they are not the same:
132/// `'dža'.capitalize()` is `'Dža'` and not `'DŽa'`.
133#[must_use]
134pub fn capitalize(points: &[u32]) -> Vec<u32> {
135 let mut out = Vec::with_capacity(points.len());
136 let Some((&first, _)) = points.split_first() else {
137 return out;
138 };
139 map(&mut out, &TITLE, first);
140 for at in 1..points.len() {
141 lowered(&mut out, points, at);
142 }
143 out
144}
145
146/// Lowercase the code point at `at`, which needs the whole string for the one
147/// case where the answer depends on it.
148fn lowered(out: &mut Vec<u32>, points: &[u32], at: usize) {
149 let cp = points[at];
150 if cp == CAPITAL_SIGMA {
151 out.push(if final_sigma(points, at) {
152 FINAL_SIGMA
153 } else {
154 SMALL_SIGMA
155 });
156 return;
157 }
158 map(out, &LOWER, cp);
159}
160
161/// Whether the sigma at `at` is at the end of a word.
162///
163/// Something cased before it and nothing cased after it, looking past
164/// case-ignorable characters on both sides. Running off the front counts as
165/// nothing cased before, and running off the end counts as nothing cased
166/// after, which is why a sigma on its own is `'σ'` and `'ας'` ends in the
167/// final form.
168fn final_sigma(points: &[u32], at: usize) -> bool {
169 let before = points[..at].iter().rev().copied();
170 let after = points[at + 1..].iter().copied();
171 reaches_cased(before) && !reaches_cased(after)
172}
173
174/// Whether the first code point in this direction that the rule does not look
175/// past is a cased one.
176///
177/// The two tests are in this order and not folded together, because a code
178/// point can be both: a modifier letter is cased and is still looked past.
179fn reaches_cased(run: impl Iterator<Item = u32>) -> bool {
180 let mut run = run;
181 run.find(|&cp| !among(&IGNORABLE, cp))
182 .is_some_and(|cp| among(&CASED, cp))
183}
184
185/// Write what `table` says about `cp`, or `cp` itself if it says nothing.
186fn map(out: &mut Vec<u32>, table: &[(u32, [u32; 3])], cp: u32) {
187 match table.binary_search_by_key(&cp, |&(from, _)| from) {
188 // Short mappings are zero padded, and a null is never a mapping, so
189 // the terminator cannot be mistaken for a result.
190 Ok(at) => out.extend(table[at].1.iter().copied().take_while(|&each| each != 0)),
191 Err(_) => out.push(cp),
192 }
193}
194
195/// Whether `cp` is lowercase, which is the `Lowercase` property and so is
196/// wider than the `Ll` category: a modifier letter such as `ʰ` is in it.
197#[must_use]
198pub fn is_lowercase(cp: u32) -> bool {
199 among(&LOWERCASE, cp)
200}
201
202/// Whether `cp` is uppercase.
203#[must_use]
204pub fn is_uppercase(cp: u32) -> bool {
205 among(&UPPERCASE, cp)
206}
207
208/// Whether `cp` is titlecase, which is neither of the other two.
209///
210/// Thirty one code points, being the three Latin digraphs and the Greek
211/// letters with an iota subscript. `Dž` is the one in the middle of `DŽ` and
212/// `dž`, and `'Dž'.swapcase()` is itself because it is neither upper nor lower.
213#[must_use]
214pub fn is_titlecase(cp: u32) -> bool {
215 among(&TITLECASE, cp)
216}
217
218/// Whether `cp` is cased at all, which is what decides where `title` sees a
219/// word start and is wider than the three above put together.
220#[must_use]
221pub fn is_cased(cp: u32) -> bool {
222 among(&CASED, cp)
223}