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}