Skip to main content

unicode_lang/
normalize.rs

1//! Unicode normalization: the four forms of [UAX #15].
2//!
3//! Normalization rewrites a string into a canonical shape so that sequences
4//! which are *meant* to be equal compare byte-for-byte equal. The classic case
5//! is `é`, which can be one precomposed scalar (`U+00E9`) or a base letter plus
6//! a combining accent (`U+0065 U+0301`); both name the same character, and
7//! normalization forces one spelling.
8//!
9//! Two axes give the four forms:
10//!
11//! - *Composition* vs *decomposition* — whether the result prefers precomposed
12//!   scalars ([`Form::Nfc`], [`Form::Nfkc`]) or fully decomposed base + marks
13//!   ([`Form::Nfd`], [`Form::Nfkd`]).
14//! - *Canonical* vs *compatibility* — canonical forms preserve visual identity;
15//!   the compatibility forms ([`Form::Nfkc`], [`Form::Nfkd`]) additionally fold
16//!   formatting distinctions, mapping e.g. the ligature `fi` to `fi` and
17//!   fullwidth `A` to `A`.
18//!
19//! For identifiers, [UAX #31] recommends NFC. For fold-and-compare tasks
20//! (case-insensitive-style matching of visually similar text) NFKC is the usual
21//! choice. When in doubt, NFC is the safe default.
22//!
23//! The implementation handles Hangul algorithmically (per UAX #15) and every
24//! other scalar through the generated decomposition, combining-class, and
25//! composition tables. It is verified against the official
26//! `NormalizationTest.txt` conformance suite.
27//!
28//! [UAX #15]: https://www.unicode.org/reports/tr15/
29//! [UAX #31]: https://www.unicode.org/reports/tr31/
30
31extern crate alloc;
32
33use alloc::string::String;
34use alloc::vec::Vec;
35
36use crate::lookup::range_value;
37use crate::tables;
38
39/// A Unicode normalization form, selecting the target shape for [`normalize`]
40/// and [`is_normalized`].
41///
42/// # Examples
43///
44/// ```
45/// use unicode_lang::{normalize, Form};
46///
47/// // NFC composes; NFD decomposes. Both round-trip the same text.
48/// let composed = normalize("e\u{0301}", Form::Nfc);
49/// assert_eq!(composed, "é");
50/// let decomposed = normalize("é", Form::Nfd);
51/// assert_eq!(decomposed, "e\u{0301}");
52/// ```
53#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
55pub enum Form {
56    /// Normalization Form C — canonical decomposition followed by canonical
57    /// composition. The most compact canonical form; the identifier default.
58    Nfc,
59    /// Normalization Form D — canonical decomposition only.
60    Nfd,
61    /// Normalization Form KC — compatibility decomposition followed by
62    /// canonical composition. Folds compatibility distinctions.
63    Nfkc,
64    /// Normalization Form KD — compatibility decomposition only.
65    Nfkd,
66}
67
68impl Form {
69    /// Whether this form recomposes after decomposing (NFC / NFKC).
70    #[inline]
71    const fn composes(self) -> bool {
72        matches!(self, Form::Nfc | Form::Nfkc)
73    }
74
75    /// Whether this form uses compatibility decomposition (NFKC / NFKD).
76    #[inline]
77    const fn compat(self) -> bool {
78        matches!(self, Form::Nfkc | Form::Nfkd)
79    }
80}
81
82// Hangul jamo composition parameters (UAX #15, "Hangul").
83const S_BASE: u32 = 0xAC00;
84const L_BASE: u32 = 0x1100;
85const V_BASE: u32 = 0x1161;
86const T_BASE: u32 = 0x11A7;
87const L_COUNT: u32 = 19;
88const V_COUNT: u32 = 21;
89const T_COUNT: u32 = 28;
90const N_COUNT: u32 = V_COUNT * T_COUNT; // 588
91const S_COUNT: u32 = L_COUNT * N_COUNT; // 11172
92
93/// Returns `s` normalized to `form`.
94///
95/// When `s` is already in the requested form this returns it unchanged after a
96/// fast allocation-free scan (the Unicode quick-check), so passing text that is
97/// already normalized — the common case for ASCII and most well-formed input —
98/// costs one linear pass and a single allocation for the returned `String`.
99///
100/// # Examples
101///
102/// ```
103/// use unicode_lang::{normalize, Form};
104///
105/// // Compatibility composition folds a ligature and a fullwidth digit.
106/// assert_eq!(normalize("file", Form::Nfkc), "file");
107/// assert_eq!(normalize("\u{FF11}", Form::Nfkc), "1");
108///
109/// // Canonical decomposition splits a precomposed character and orders marks.
110/// assert_eq!(normalize("ǭ", Form::Nfd), "o\u{0328}\u{0304}");
111///
112/// // ASCII is returned untouched.
113/// assert_eq!(normalize("plain ascii", Form::Nfc), "plain ascii");
114/// ```
115#[must_use]
116pub fn normalize(s: &str, form: Form) -> String {
117    if let Quick::Yes = quick_check(s, form) {
118        return String::from(s);
119    }
120    let mut buf = decompose(s, form.compat());
121    if form.composes() {
122        compose(&mut buf);
123    }
124    buf.into_iter().collect()
125}
126
127/// Returns `true` if `s` is already in normalization form `form`.
128///
129/// The check begins with the allocation-free Unicode quick-check. Most inputs
130/// are decided there; only genuinely ambiguous input triggers a full
131/// normalization to resolve, and even then no `String` is allocated — the
132/// normalized scalars are compared against `s` directly.
133///
134/// # Examples
135///
136/// ```
137/// use unicode_lang::{is_normalized, Form};
138///
139/// assert!(is_normalized("é", Form::Nfc));            // precomposed: already NFC
140/// assert!(!is_normalized("e\u{0301}", Form::Nfc));   // decomposed: not NFC
141/// assert!(is_normalized("e\u{0301}", Form::Nfd));    // ...but it is NFD
142///
143/// assert!(is_normalized("ascii only", Form::Nfkc));
144/// assert!(!is_normalized("fi", Form::Nfkc));          // ligature folds under NFKC
145/// ```
146#[must_use]
147pub fn is_normalized(s: &str, form: Form) -> bool {
148    match quick_check(s, form) {
149        Quick::Yes => true,
150        Quick::No => false,
151        Quick::Maybe => {
152            // Resolve the ambiguous case by normalizing and comparing scalars,
153            // without materialising an intermediate `String`.
154            let mut buf = decompose(s, form.compat());
155            if form.composes() {
156                compose(&mut buf);
157            }
158            buf.into_iter().eq(s.chars())
159        }
160    }
161}
162
163/// Outcome of the Unicode quick-check: definitely normalized, definitely not,
164/// or requires a full pass to decide.
165enum Quick {
166    Yes,
167    No,
168    Maybe,
169}
170
171/// The [quick-check algorithm][qc]: scans `s` once, consulting the per-form
172/// `*_QC` property and the canonical ordering of combining marks.
173///
174/// [qc]: https://www.unicode.org/reports/tr15/#Detecting_Normalization_Forms
175fn quick_check(s: &str, form: Form) -> Quick {
176    let qc = match form {
177        Form::Nfc => tables::NFC_QC,
178        Form::Nfd => tables::NFD_QC,
179        Form::Nfkc => tables::NFKC_QC,
180        Form::Nfkd => tables::NFKD_QC,
181    };
182    let mut last_ccc = 0u8;
183    let mut result = Quick::Yes;
184    for c in s.chars() {
185        let cc = ccc(c);
186        // Marks out of canonical order prove the string is not normalized.
187        if last_ccc > cc && cc != 0 {
188            return Quick::No;
189        }
190        match range_value(c as u32, qc) {
191            1 => return Quick::No,
192            2 => result = Quick::Maybe,
193            _ => {}
194        }
195        last_ccc = cc;
196    }
197    result
198}
199
200/// Canonical combining class of `c` (0 for the vast majority of scalars).
201#[inline]
202fn ccc(c: char) -> u8 {
203    range_value(c as u32, tables::CCC)
204}
205
206/// Fully decompose `s` (canonical, or compatibility when `compat`) and place
207/// the result in canonical order.
208fn decompose(s: &str, compat: bool) -> Vec<char> {
209    let mut out: Vec<char> = Vec::with_capacity(s.len());
210    for c in s.chars() {
211        decompose_char(c, compat, &mut out);
212    }
213    canonical_order(&mut out);
214    out
215}
216
217/// Append the full decomposition of one scalar to `out`.
218fn decompose_char(c: char, compat: bool, out: &mut Vec<char>) {
219    let cp = c as u32;
220    if (S_BASE..S_BASE + S_COUNT).contains(&cp) {
221        hangul_decompose(cp, out);
222        return;
223    }
224    let (index, data): (&[(u32, u32, u32)], &[u32]) = if compat {
225        (tables::COMPAT_DECOMP, tables::COMPAT_DATA)
226    } else {
227        (tables::CANON_DECOMP, tables::CANON_DATA)
228    };
229    if let Ok(i) = index.binary_search_by_key(&cp, |&(key, _, _)| key) {
230        let (_, off, len) = index[i];
231        let (off, len) = (off as usize, len as usize);
232        out.extend(
233            data[off..off + len]
234                .iter()
235                .filter_map(|&d| char::from_u32(d)),
236        );
237    } else {
238        out.push(c);
239    }
240}
241
242/// Algorithmic Hangul syllable decomposition (LV or LVT jamo).
243fn hangul_decompose(cp: u32, out: &mut Vec<char>) {
244    let s = cp - S_BASE;
245    push_scalar(out, L_BASE + s / N_COUNT);
246    push_scalar(out, V_BASE + (s % N_COUNT) / T_COUNT);
247    let t = s % T_COUNT;
248    if t != 0 {
249        push_scalar(out, T_BASE + t);
250    }
251}
252
253#[inline]
254fn push_scalar(out: &mut Vec<char>, cp: u32) {
255    if let Some(c) = char::from_u32(cp) {
256        out.push(c);
257    }
258}
259
260/// Reorder combining marks into canonical order: a stable sort by combining
261/// class within each run of non-starter scalars. Starters (class 0) are fixed
262/// points and act as barriers, so this is an insertion sort that never moves a
263/// mark past a starter.
264fn canonical_order(chars: &mut [char]) {
265    let n = chars.len();
266    let mut i = 1;
267    while i < n {
268        let cc = ccc(chars[i]);
269        if cc != 0 {
270            let mut j = i;
271            while j > 0 && ccc(chars[j - 1]) > cc {
272                chars.swap(j - 1, j);
273                j -= 1;
274            }
275        }
276        i += 1;
277    }
278}
279
280/// Canonically compose a decomposed, canonically ordered scalar buffer in
281/// place (the recomposition step of NFC / NFKC).
282fn compose(chars: &mut Vec<char>) {
283    if chars.len() < 2 {
284        return;
285    }
286    let mut out: Vec<char> = Vec::with_capacity(chars.len());
287    // Index in `out` of the most recent starter that can still absorb a
288    // following combining mark, and the combining class of the last scalar
289    // pushed (0 while the starter is still bare).
290    let mut starter: Option<usize> = None;
291    let mut last_ccc = 0u8;
292
293    for &c in chars.iter() {
294        let cc = ccc(c);
295        if let Some(sp) = starter {
296            // A combining mark is blocked from the starter if some scalar
297            // between them has an equal-or-greater class. Because the buffer is
298            // canonically ordered, tracking only the previous class suffices.
299            if last_ccc == 0 || last_ccc < cc {
300                if let Some(composite) = primary_composite(out[sp], c) {
301                    out[sp] = composite;
302                    continue;
303                }
304            }
305        }
306        out.push(c);
307        if cc == 0 {
308            starter = Some(out.len() - 1);
309            last_ccc = 0;
310        } else {
311            last_ccc = cc;
312        }
313    }
314    *chars = out;
315}
316
317/// The primary composite of a starter `a` and following scalar `b`, if one
318/// exists: Hangul jamo by formula, everything else by table.
319fn primary_composite(a: char, b: char) -> Option<char> {
320    let (ca, cb) = (a as u32, b as u32);
321
322    // Hangul: leading + vowel jamo -> LV syllable.
323    if (L_BASE..L_BASE + L_COUNT).contains(&ca) && (V_BASE..V_BASE + V_COUNT).contains(&cb) {
324        let li = ca - L_BASE;
325        let vi = cb - V_BASE;
326        return char::from_u32(S_BASE + (li * V_COUNT + vi) * T_COUNT);
327    }
328    // Hangul: LV syllable + trailing jamo -> LVT syllable.
329    if (S_BASE..S_BASE + S_COUNT).contains(&ca)
330        && (ca - S_BASE) % T_COUNT == 0
331        && (T_BASE + 1..T_BASE + T_COUNT).contains(&cb)
332    {
333        return char::from_u32(ca + (cb - T_BASE));
334    }
335
336    let key = (u64::from(ca) << 32) | u64::from(cb);
337    match tables::COMPOSE.binary_search_by_key(&key, |&(k, _)| k) {
338        Ok(i) => char::from_u32(tables::COMPOSE[i].1),
339        Err(_) => None,
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_nfc_composes_base_and_mark() {
349        assert_eq!(normalize("e\u{0301}", Form::Nfc), "é");
350    }
351
352    #[test]
353    fn test_nfd_decomposes_precomposed() {
354        assert_eq!(normalize("é", Form::Nfd), "e\u{0301}");
355    }
356
357    #[test]
358    fn test_nfd_orders_marks_by_class() {
359        // Two marks given out of canonical order (class 230 then 220) must be
360        // reordered (220 before 230).
361        let input = "a\u{0301}\u{0323}"; // acute (230) then dot-below (220)
362        assert_eq!(normalize(input, Form::Nfd), "a\u{0323}\u{0301}");
363    }
364
365    #[test]
366    fn test_nfkc_folds_ligature() {
367        assert_eq!(normalize("fi", Form::Nfkc), "fi");
368        assert_eq!(normalize("\u{FF21}", Form::Nfkc), "A"); // fullwidth A
369    }
370
371    #[test]
372    fn test_nfkd_expands_compatibility() {
373        assert_eq!(normalize("½", Form::Nfkd), "1\u{2044}2");
374    }
375
376    #[test]
377    fn test_hangul_roundtrip() {
378        let syllable = "\u{AC00}"; // 가
379        let decomposed = normalize(syllable, Form::Nfd);
380        assert_eq!(decomposed, "\u{1100}\u{1161}");
381        assert_eq!(normalize(&decomposed, Form::Nfc), syllable);
382    }
383
384    #[test]
385    fn test_hangul_lvt() {
386        let syllable = "\u{AC01}"; // 각 (LVT)
387        assert_eq!(normalize(syllable, Form::Nfd), "\u{1100}\u{1161}\u{11A8}");
388        assert_eq!(normalize("\u{1100}\u{1161}\u{11A8}", Form::Nfc), syllable);
389    }
390
391    #[test]
392    fn test_ascii_unchanged() {
393        for form in [Form::Nfc, Form::Nfd, Form::Nfkc, Form::Nfkd] {
394            assert_eq!(normalize("hello world 123", form), "hello world 123");
395        }
396    }
397
398    #[test]
399    fn test_idempotent() {
400        let samples = ["e\u{0301}", "fi", "가", "½", "A", "a\u{0323}\u{0301}"];
401        for s in samples {
402            for form in [Form::Nfc, Form::Nfd, Form::Nfkc, Form::Nfkd] {
403                let once = normalize(s, form);
404                let twice = normalize(&once, form);
405                assert_eq!(once, twice, "not idempotent: {s:?} {form:?}");
406            }
407        }
408    }
409
410    #[cfg(feature = "serde")]
411    #[test]
412    #[allow(clippy::unwrap_used)] // serde_json in a test; failure is a test failure
413    fn test_form_serde_roundtrip() {
414        for form in [Form::Nfc, Form::Nfd, Form::Nfkc, Form::Nfkd] {
415            let json = serde_json::to_string(&form).unwrap();
416            let back: Form = serde_json::from_str(&json).unwrap();
417            assert_eq!(form, back);
418        }
419        assert_eq!(serde_json::to_string(&Form::Nfkc).unwrap(), "\"Nfkc\"");
420    }
421
422    #[test]
423    fn test_is_normalized_agrees_with_normalize() {
424        let samples = [
425            "",
426            "abc",
427            "é",
428            "e\u{0301}",
429            "fi",
430            "가",
431            "½",
432            "a\u{0323}\u{0301}",
433        ];
434        for s in samples {
435            for form in [Form::Nfc, Form::Nfd, Form::Nfkc, Form::Nfkd] {
436                let expected = normalize(s, form) == s;
437                assert_eq!(is_normalized(s, form), expected, "{s:?} {form:?}");
438            }
439        }
440    }
441}