paginate_core/
normalize.rs1use std::borrow::Cow;
20
21use unicode_normalization::char::is_combining_mark;
22use unicode_normalization::UnicodeNormalization;
23
24#[must_use]
26pub fn normalize_text(value: &str) -> String {
27 if value.is_ascii() {
28 return normalize_ascii(value);
29 }
30 let stripped: String = value.nfkd().filter(|c| !is_combining_mark(*c)).collect();
31 collapse_whitespace(&stripped.to_lowercase())
32}
33
34#[must_use]
38pub fn normalize_text_cow(value: &str) -> Cow<'_, str> {
39 if value.is_ascii() && is_ascii_normalized(value) {
40 return Cow::Borrowed(value);
41 }
42 Cow::Owned(normalize_text(value))
43}
44
45fn is_ascii_normalized(s: &str) -> bool {
50 let mut prev_space = true; for &b in s.as_bytes() {
52 if b.is_ascii_uppercase() {
53 return false;
54 }
55 if is_ascii_ws(b) {
56 if b != b' ' || prev_space {
57 return false; }
59 prev_space = true;
60 } else {
61 prev_space = false;
62 }
63 }
64 !prev_space }
66
67fn is_ascii_ws(b: u8) -> bool {
71 matches!(b, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r')
72}
73
74fn normalize_ascii(value: &str) -> String {
78 let mut out = String::with_capacity(value.len());
79 let mut pending_space = false;
80 for &b in value.as_bytes() {
81 if is_ascii_ws(b) {
82 pending_space = !out.is_empty();
83 continue;
84 }
85 if pending_space {
86 out.push(' ');
87 pending_space = false;
88 }
89 out.push(b.to_ascii_lowercase() as char);
90 }
91 out
92}
93
94fn collapse_whitespace(s: &str) -> String {
97 let mut out = String::with_capacity(s.len());
98 for word in s.split_whitespace() {
99 if !out.is_empty() {
100 out.push(' ');
101 }
102 out.push_str(word);
103 }
104 out
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn ascii_lowercases_and_collapses() {
113 assert_eq!(normalize_text("Hello World"), "hello world");
114 assert_eq!(normalize_text(" Foo Bar "), "foo bar");
115 assert_eq!(normalize_text("UPPER"), "upper");
116 assert_eq!(normalize_text(""), "");
117 assert_eq!(normalize_text(" "), "");
118 }
119
120 #[test]
121 fn strips_accents_on_latin_text() {
122 assert_eq!(normalize_text("Café"), "cafe");
123 assert_eq!(normalize_text("JOSÉ"), "jose");
124 assert_eq!(normalize_text("naïve"), "naive");
125 assert_eq!(normalize_text("Crème Brûlée"), "creme brulee");
126 }
127
128 #[test]
129 fn idempotent() {
130 let once = normalize_text("Héllo WÖRLD");
131 assert_eq!(normalize_text(&once), once);
132 }
133
134 #[test]
135 fn cow_always_equals_owned_and_borrows_when_already_normalized() {
136 use std::borrow::Cow;
137 let cases = [
140 "user42@test.com",
141 "hello world",
142 "abc",
143 "already_normalized-slug.v2",
144 "Hello",
145 " lead",
146 "trail ",
147 "a b",
148 "a\tb",
149 "Café",
150 "MiXeD Case",
151 "",
152 "x",
153 "a b c",
154 "UPPER",
155 "with\nnewline",
156 ];
157 for s in cases {
158 assert_eq!(
159 normalize_text_cow(s).as_ref(),
160 normalize_text(s),
161 "value={s:?}"
162 );
163 }
164 assert!(matches!(
166 normalize_text_cow("user42@test.com"),
167 Cow::Borrowed(_)
168 ));
169 assert!(matches!(
170 normalize_text_cow("hello world"),
171 Cow::Borrowed(_)
172 ));
173 assert!(matches!(normalize_text_cow("Hello"), Cow::Owned(_)));
175 assert!(matches!(normalize_text_cow("a b"), Cow::Owned(_)));
176 assert!(matches!(normalize_text_cow(" x"), Cow::Owned(_)));
177 assert!(matches!(normalize_text_cow("x "), Cow::Owned(_)));
178 assert!(matches!(normalize_text_cow("a\tb"), Cow::Owned(_)));
179 assert!(matches!(normalize_text_cow("Café"), Cow::Owned(_)));
180 }
181
182 #[test]
183 fn ascii_single_pass_matches_python_split_semantics() {
184 assert_eq!(normalize_text("a\u{0b}b\u{0c}c"), "a b c");
187 assert_eq!(normalize_text("\tTAB\tand spaces\n"), "tab and spaces");
188 assert_eq!(normalize_text("Multi Word Test"), "multi word test");
189 }
190}