Skip to main content

paginate_core/
normalize.rs

1//! Text normalization for search and filtering.
2//!
3//! Port of pypaginate's `text/normalize.py`:
4//!
5//! * **ASCII fast path** — lowercase then collapse whitespace. On ASCII,
6//!   `str.casefold()` equals lowercasing, so this is byte-identical to Python.
7//! * **Non-ASCII path** — NFKD-decompose, drop combining marks, lowercase,
8//!   collapse whitespace.
9//!
10//! The bounded result cache stays in the binding layer (exactly where the
11//! Python module keeps it), so this function is referentially transparent and
12//! trivially portable.
13//!
14//! Known minor divergences from `str.casefold()` on the non-ASCII path: full
15//! case folds such as `ß → ss` are not applied (we lowercase instead), and
16//! spacing/enclosing marks (Mc/Me) are also dropped, not just nonspacing (Mn).
17//! These affect a tiny fraction of inputs and never the ASCII fast path.
18
19use std::borrow::Cow;
20
21use unicode_normalization::char::is_combining_mark;
22use unicode_normalization::UnicodeNormalization;
23
24/// Normalize `value` for case- and accent-insensitive matching.
25#[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/// Like [`normalize_text`], but **borrows** `value` unchanged when it is already
35/// normalized (the common case for search fields like emails, ids, and slugs),
36/// so the per-item search hot path allocates nothing for already-clean text.
37#[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
45/// True iff [`normalize_ascii`] would return `s` unchanged: pure ASCII, no
46/// uppercase, and whitespace only as single `' '` separators (none leading,
47/// trailing, repeated, or non-space like `\t`/`\n`). When this holds,
48/// `normalize_text(s) == s`, so the caller can borrow instead of reallocate.
49fn is_ascii_normalized(s: &str) -> bool {
50    let mut prev_space = true; // start "after a space" so a leading space is rejected
51    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; // non-space whitespace, or a leading/repeated space
58            }
59            prev_space = true;
60        } else {
61            prev_space = false;
62        }
63    }
64    !prev_space // reject a trailing space (and the empty string, which is cheap to own)
65}
66
67/// ASCII whitespace per `char::is_whitespace` (so `split_whitespace` parity is
68/// preserved): note `\x0b` (VT) and `\x0c` (FF), which `is_ascii_whitespace`
69/// omits, count here.
70fn is_ascii_ws(b: u8) -> bool {
71    matches!(b, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r')
72}
73
74/// Single-pass ASCII normalize — lowercase + collapse whitespace runs to single
75/// spaces + trim ends, in ONE allocation. Byte-identical to
76/// `" ".join(value.lower().split())` on ASCII (the common case).
77fn 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
94/// Collapse whitespace runs to single spaces and trim the ends —
95/// equivalent to Python's `" ".join(s.split())` — without the intermediate Vec.
96fn 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        // The borrowing variant must produce byte-identical output to the owned
138        // one for every input — borrowing is only an allocation optimization.
139        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        // Already-normalized text borrows (zero allocation)...
165        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        // ...everything needing work (case, accents, whitespace) is owned.
174        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        // VT (\x0b) and FF (\x0c) count as whitespace (char::is_whitespace),
185        // matching Python's str.split(); leading/trailing/runs collapse.
186        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}