Strings and Text Processing
Unicode Normalization Considerations
In Unicode, many visually identical characters can be represented by different sequences of code points (such as a single precomposed character vs. a base letter followed by a combining mark). Because Rust's standard ==, Ord, and Hash implementations on str and String compare raw UTF-8 bytes, visually identical strings in different normalization forms compare as not equal.
Precomposed (NFC) vs. Decomposed (NFD) Representations
Consider the character é (Latin small letter e with acute):
- NFC (Canonical Decomposition, followed by Canonical Composition): A single code point
U+00E9('é', 2 UTF-8 bytes[0xC3, 0xA9]). - NFD (Canonical Decomposition): Two code points—ASCII
'e'(U+0065, 1 byte[0x65]) followed by the combining acute accentU+0301(2 bytes[0xCC, 0x81]), totaling 3 UTF-8 bytes.
NFC ("\u{00E9}"): [ 0xC3, 0xA9 ] (1 char, 2 bytes)
NFD ("e\u{0301}"): [ 0x65, 0xCC, 0x81 ] (2 chars, 3 bytes)
fn main() {
let nfc = "caf\u{00E9}"; // Precomposed 'é' (typical on Linux/Windows/Web)
let nfd = "cafe\u{0301}"; // Decomposed 'e' + combining acute (typical on macOS HFS+/APFS filenames)
println!("NFC: \"{nfc}\" (bytes = {:?}, chars = {})", nfc.as_bytes(), nfc.chars().count());
println!("NFD: \"{nfd}\" (bytes = {:?}, chars = {})", nfd.as_bytes(), nfd.chars().count());
println!("nfc == nfd (byte equality): {}", nfc == nfd);
}
NFC: "café" (bytes = [99, 97, 102, 195, 169], chars = 4)
NFD: "café" (bytes = [99, 97, 102, 101, 204, 129], chars = 5)
nfc == nfd (byte equality): false
The Four Standard Unicode Normalization Forms
| Form | Name | Behavior | Typical Use Case |
|---|---|---|---|
| NFC | Canonical Composition | Decomposes then recomposes into shortest canonical code points | W3C / Web standard text storage, usernames, display text |
| NFD | Canonical Decomposition | Splits precomposed characters into base + combining marks | Internal text processing, stripping accents, macOS APFS filenames |
| NFKC | Compatibility Composition | Replaces compatibility variants (e.g., 'fi' ligature -> "fi", '²' -> '2', fullwidth 'A' -> 'A') then composes |
Identifier matching (UAX #31), search indexing, security deduplication |
| NFKD | Compatibility Decomposition | Compatibility decomposition without recomposition | Fuzzy full-text search and transliteration |
Why the Standard Library Does Not Bundle Normalization Tables
Full Unicode normalization tables (unicode-normalization crate) are large and evolve across Unicode versions, whereas core and std keep str equality and hashing fast ($O(n)$ byte memcmp). When comparing user-supplied identifiers, passwords, or cross-platform filenames:
- Normalize inputs at the boundary (typically to NFC for general text or NFKC for identifiers/search queries) using the
unicode-normalizationcrate (s.nfc().collect::<String>()). - Store and hash only the normalized
Stringso standard==andHashMaplookups remain $O(n)$ byte comparisons.