Skip to main content

oxide_regexp/unicode/
normalize.rs

1//! Checksum-pinned Unicode 17 normalization used by String intrinsics.
2//!
3//! This is a direct Rust port of QuickJS's `unicode_normalize`, including its
4//! compressed decomposition, composition, and canonical-combining-class table
5//! readers. It deliberately does not use a Rust Unicode crate: QuickJS pins a
6//! particular Unicode release, and ECMAScript strings must preserve lone
7//! UTF-16 surrogates that Rust scalar-value APIs cannot represent.
8
9use super::{CodeUnitBuf, CodeUnits, MAX_STRING_LEN, StringError, index_position};
10#[cfg(any(test, feature = "test-hooks"))]
11use std::cell::Cell;
12use std::cmp::Ordering;
13
14mod tables {
15    include!("generated/unicode/unicode_normalize_tables.rs");
16}
17
18const DECOMPOSITION_MAX_LEN: usize = 18;
19
20const DECOMP_TYPE_C1: u32 = 0;
21const DECOMP_TYPE_L1: u32 = 1;
22const DECOMP_TYPE_L7: u32 = 7;
23const DECOMP_TYPE_LL1: u32 = 8;
24const DECOMP_TYPE_LL2: u32 = 9;
25const DECOMP_TYPE_S1: u32 = 10;
26const DECOMP_TYPE_S5: u32 = 14;
27const DECOMP_TYPE_I1: u32 = 15;
28const DECOMP_TYPE_I2_0: u32 = 16;
29const DECOMP_TYPE_I4_2: u32 = 21;
30const DECOMP_TYPE_B1: u32 = 22;
31const DECOMP_TYPE_B8: u32 = 29;
32const DECOMP_TYPE_B18: u32 = 30;
33const DECOMP_TYPE_LS2: u32 = 31;
34const DECOMP_TYPE_PAT3: u32 = 32;
35const DECOMP_TYPE_S2_UL: u32 = 33;
36const DECOMP_TYPE_LS2_UL: u32 = 34;
37
38/// The four normalization forms accepted by `String.prototype.normalize`.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum NormalizationForm {
41    Nfc,
42    Nfd,
43    Nfkc,
44    Nfkd,
45}
46
47impl NormalizationForm {
48    const fn is_compatibility(self) -> bool {
49        matches!(self, Self::Nfkc | Self::Nfkd)
50    }
51
52    const fn is_decomposed(self) -> bool {
53        matches!(self, Self::Nfd | Self::Nfkd)
54    }
55}
56
57#[cfg(any(test, feature = "test-hooks"))]
58thread_local! {
59    static FAIL_NEXT_NORMALIZE_RESERVATION: Cell<bool> = const { Cell::new(false) };
60}
61
62/// Force the next normalization buffer reservation to fail. The hook is
63/// thread-local so concurrently running tests cannot consume one another's
64/// injected failure.
65#[cfg(any(test, feature = "test-hooks"))]
66pub fn fail_next_normalize_reservation_for_test() {
67    FAIL_NEXT_NORMALIZE_RESERVATION.with(|armed| {
68        assert!(
69            !armed.replace(true),
70            "normalization reservation failure was already armed"
71        );
72    });
73}
74
75fn try_reserve_exact<T>(values: &mut Vec<T>, additional: usize) -> Result<(), StringError> {
76    #[cfg(any(test, feature = "test-hooks"))]
77    if FAIL_NEXT_NORMALIZE_RESERVATION.with(|armed| armed.replace(false)) {
78        return Err(StringError::OutOfMemory);
79    }
80    values
81        .try_reserve_exact(additional)
82        .map_err(|_| StringError::OutOfMemory)
83}
84
85fn push_fallible<T>(values: &mut Vec<T>, value: T) -> Result<(), StringError> {
86    if values.len() == values.capacity() {
87        // Match QuickJS DynBuf's 1.5x growth policy. Reserving exactly one
88        // element here would make repeated compatibility decompositions such
89        // as U+FDFA perform O(n^2) reallocations once they outgrow the input-
90        // sized initial buffer, which is especially costly under WebAssembly.
91        let additional = (values.capacity() / 2).max(1);
92        try_reserve_exact(values, additional)?;
93    }
94    values.push(value);
95    Ok(())
96}
97
98fn get_u16(data: &[u8], position: usize) -> u32 {
99    u32::from(data[position]) | (u32::from(data[position + 1]) << 8)
100}
101
102fn short_code(code: u32) -> u32 {
103    const SHORT_TABLE: [u32; 2] = [0x2044, 0x2215];
104    if code < 0x80 {
105        code
106    } else if code < 0xd0 {
107        code - 0x80 + 0x300
108    } else {
109        SHORT_TABLE[(code - 0xd0) as usize]
110    }
111}
112
113fn lower_simple(mut code_point: u32) -> u32 {
114    if code_point < 0x100 || (0x410..=0x42f).contains(&code_point) {
115        code_point += 0x20;
116    } else {
117        code_point += 1;
118    }
119    code_point
120}
121
122fn decomposition_entry(
123    output: &mut [u32; DECOMPOSITION_MAX_LEN],
124    code_point: u32,
125    table_index: usize,
126    run_start: u32,
127    run_len: u32,
128    run_type: u32,
129) -> usize {
130    if run_type == DECOMP_TYPE_C1 {
131        output[0] = u32::from(tables::UNICODE_DECOMP_TABLE2[table_index]);
132        return 1;
133    }
134
135    let data_offset = usize::from(tables::UNICODE_DECOMP_TABLE2[table_index]);
136    let data = &tables::UNICODE_DECOMP_DATA[data_offset..];
137    let delta = (code_point - run_start) as usize;
138
139    match run_type {
140        DECOMP_TYPE_L1..=DECOMP_TYPE_L7 => {
141            let len = (run_type - DECOMP_TYPE_L1 + 1) as usize;
142            let data = &data[delta * len * 2..];
143            for (index, slot) in output[..len].iter_mut().enumerate() {
144                *slot = get_u16(data, index * 2);
145                if *slot == 0 {
146                    return 0;
147                }
148            }
149            len
150        }
151        DECOMP_TYPE_LL1..=DECOMP_TYPE_LL2 => {
152            let len = (run_type - DECOMP_TYPE_LL1 + 1) as usize;
153            let high_bits_offset = run_len as usize * len * 2;
154            for (item_index, slot) in (delta * len..).zip(output[..len].iter_mut()) {
155                let high_bits =
156                    (data[high_bits_offset + item_index / 4] >> ((item_index % 4) * 2)) & 3;
157                *slot = get_u16(data, item_index * 2) | (u32::from(high_bits) << 16);
158                if *slot == 0 {
159                    return 0;
160                }
161            }
162            len
163        }
164        DECOMP_TYPE_S1..=DECOMP_TYPE_S5 => {
165            let len = (run_type - DECOMP_TYPE_S1 + 1) as usize;
166            let data = &data[delta * len..];
167            for (index, slot) in output[..len].iter_mut().enumerate() {
168                *slot = short_code(u32::from(data[index]));
169                if *slot == 0 {
170                    return 0;
171                }
172            }
173            len
174        }
175        DECOMP_TYPE_I1 => {
176            output[0] = get_u16(data, 0) + code_point - run_start;
177            1
178        }
179        DECOMP_TYPE_I2_0..=DECOMP_TYPE_I4_2 => {
180            let len = (2 + ((run_type - DECOMP_TYPE_I2_0) >> 1)) as usize;
181            let incremented = ((run_type - DECOMP_TYPE_I2_0) & 1) as usize + usize::from(len > 2);
182            for (index, slot) in output[..len].iter_mut().enumerate() {
183                *slot = get_u16(data, index * 2);
184                if index == incremented {
185                    *slot += code_point - run_start;
186                }
187            }
188            len
189        }
190        DECOMP_TYPE_B1..=DECOMP_TYPE_B8 | DECOMP_TYPE_B18 => {
191            let len = if run_type == DECOMP_TYPE_B18 {
192                18
193            } else {
194                (run_type - DECOMP_TYPE_B1 + 1) as usize
195            };
196            let minimum = get_u16(data, 0);
197            let data = &data[2 + delta * len..];
198            for (index, slot) in output[..len].iter_mut().enumerate() {
199                *slot = if data[index] == 0xff {
200                    0x20
201                } else {
202                    minimum + u32::from(data[index])
203                };
204            }
205            len
206        }
207        DECOMP_TYPE_LS2 => {
208            let data = &data[delta * 3..];
209            output[0] = get_u16(data, 0);
210            if output[0] == 0 {
211                return 0;
212            }
213            output[1] = short_code(u32::from(data[2]));
214            2
215        }
216        DECOMP_TYPE_PAT3 => {
217            output[0] = get_u16(data, 0);
218            output[2] = get_u16(data, 2);
219            output[1] = get_u16(data, 4 + delta * 2);
220            3
221        }
222        DECOMP_TYPE_S2_UL | DECOMP_TYPE_LS2_UL => {
223            let mut data = data;
224            let mut first;
225            if run_type == DECOMP_TYPE_S2_UL {
226                data = &data[delta & !1..];
227                first = short_code(u32::from(data[0]));
228                data = &data[1..];
229            } else {
230                data = &data[(delta >> 1) * 3..];
231                first = get_u16(data, 0);
232                data = &data[2..];
233            }
234            if delta & 1 != 0 {
235                first = lower_simple(first);
236            }
237            output[0] = first;
238            output[1] = short_code(u32::from(data[0]));
239            2
240        }
241        _ => 0,
242    }
243}
244
245fn decomposition(
246    output: &mut [u32; DECOMPOSITION_MAX_LEN],
247    code_point: u32,
248    compatibility: bool,
249) -> usize {
250    let mut lower = 0_usize;
251    let mut upper = tables::UNICODE_DECOMP_TABLE1.len();
252    while lower < upper {
253        let index = (lower + upper) / 2;
254        let entry = tables::UNICODE_DECOMP_TABLE1[index];
255        let run_start = entry >> 14;
256        let run_len = (entry >> 7) & 0x7f;
257        if code_point < run_start {
258            upper = index;
259        } else if code_point >= run_start + run_len {
260            lower = index + 1;
261        } else {
262            let is_compatibility = entry & 1 != 0;
263            if is_compatibility && !compatibility {
264                return 0;
265            }
266            let run_type = (entry >> 1) & 0x3f;
267            return decomposition_entry(output, code_point, index, run_start, run_len, run_type);
268        }
269    }
270    0
271}
272
273fn append_decomposed(
274    output: &mut Vec<u32>,
275    code_point: u32,
276    compatibility: bool,
277) -> Result<(), StringError> {
278    if (0xac00..0xd7a4).contains(&code_point) {
279        let syllable = code_point - 0xac00;
280        push_fallible(output, 0x1100 + syllable / 588)?;
281        push_fallible(output, 0x1161 + (syllable % 588) / 28)?;
282        let trailing = syllable % 28;
283        if trailing != 0 {
284            push_fallible(output, 0x11a7 + trailing)?;
285        }
286        return Ok(());
287    }
288
289    let mut mapped = [0_u32; DECOMPOSITION_MAX_LEN];
290    let mapped_len = decomposition(&mut mapped, code_point, compatibility);
291    if mapped_len == 0 {
292        push_fallible(output, code_point)
293    } else {
294        for mapped_code_point in &mapped[..mapped_len] {
295            append_decomposed(output, *mapped_code_point, compatibility)?;
296        }
297        Ok(())
298    }
299}
300
301fn combining_class(code_point: u32) -> u32 {
302    let Some((mut code, mut position)) = index_position(code_point, &tables::UNICODE_CC_INDEX)
303    else {
304        return 0;
305    };
306
307    loop {
308        let byte = tables::UNICODE_CC_TABLE[position];
309        position += 1;
310        let class_type = byte >> 6;
311        let mut run_len = u32::from(byte & 0x3f);
312        if (48..56).contains(&run_len) {
313            run_len = ((run_len - 48) << 8) | u32::from(tables::UNICODE_CC_TABLE[position]);
314            position += 1;
315            run_len += 48;
316        } else if run_len >= 56 {
317            run_len = ((run_len - 56) << 16)
318                | (u32::from(tables::UNICODE_CC_TABLE[position]) << 8)
319                | u32::from(tables::UNICODE_CC_TABLE[position + 1]);
320            position += 2;
321            run_len += 48 + (1 << 11);
322        }
323        let explicit_class = if class_type <= 1 {
324            let class = tables::UNICODE_CC_TABLE[position];
325            position += 1;
326            Some(class)
327        } else {
328            None
329        };
330        let run_end = code + run_len + 1;
331        if code_point < run_end {
332            return match class_type {
333                0 => u32::from(explicit_class.expect("class-zero run lost its payload")),
334                1 => {
335                    u32::from(explicit_class.expect("linear class run lost its payload"))
336                        + code_point
337                        - code
338                }
339                2 => 0,
340                _ => 230,
341            };
342        }
343        code = run_end;
344    }
345}
346
347fn canonical_order(code_points: &mut [u32]) {
348    let mut index = 0;
349    while index < code_points.len() {
350        if combining_class(code_points[index]) == 0 {
351            index += 1;
352            continue;
353        }
354
355        let start = index;
356        let mut end = start + 1;
357        while end < code_points.len() && combining_class(code_points[end]) != 0 {
358            let code_point = code_points[end];
359            let class = combining_class(code_point);
360            let mut insertion = end;
361            while insertion > start && combining_class(code_points[insertion - 1]) > class {
362                code_points[insertion] = code_points[insertion - 1];
363                insertion -= 1;
364            }
365            code_points[insertion] = code_point;
366            end += 1;
367        }
368        index = end;
369    }
370}
371
372fn unicode_compose_pair(first: u32, second: u32) -> Option<u32> {
373    let mut lower = 0_usize;
374    let mut upper = tables::UNICODE_COMP_TABLE.len();
375    while lower < upper {
376        let index = (lower + upper) / 2;
377        let decomposition_index = tables::UNICODE_COMP_TABLE[index];
378        let table_index = usize::from(decomposition_index >> 6);
379        let run_offset = u32::from(decomposition_index & 0x3f);
380        let entry = tables::UNICODE_DECOMP_TABLE1[table_index];
381        let run_start = entry >> 14;
382        let run_len = (entry >> 7) & 0x7f;
383        let run_type = (entry >> 1) & 0x3f;
384        let composed = run_start + run_offset;
385        let mut pair = [0_u32; DECOMPOSITION_MAX_LEN];
386        let pair_len = decomposition_entry(
387            &mut pair,
388            composed,
389            table_index,
390            run_start,
391            run_len,
392            run_type,
393        );
394        debug_assert_eq!(pair_len, 2);
395        match (first, second).cmp(&(pair[0], pair[1])) {
396            Ordering::Less => upper = index,
397            Ordering::Greater => lower = index + 1,
398            Ordering::Equal => return Some(composed),
399        }
400    }
401    None
402}
403
404fn compose_pair(first: u32, second: u32) -> Option<u32> {
405    if (0x1100..0x1100 + 19).contains(&first) && (0x1161..0x1161 + 21).contains(&second) {
406        Some(0xac00 + (first - 0x1100) * 588 + (second - 0x1161) * 28)
407    } else if (0xac00..0xac00 + 11_172).contains(&first)
408        && (first - 0xac00).is_multiple_of(28)
409        && (0x11a7..0x11a7 + 28).contains(&second)
410    {
411        Some(first + second - 0x11a7)
412    } else {
413        unicode_compose_pair(first, second)
414    }
415}
416
417fn compose(code_points: &mut Vec<u32>) {
418    if code_points.len() <= 1 {
419        return;
420    }
421
422    let input_len = code_points.len();
423    let mut input = 1_usize;
424    let mut output_len = 1_usize;
425    while input < input_len {
426        let mut last_class = combining_class(code_points[input]);
427        let mut starter = output_len.cast_signed() - 1;
428        let mut blocked = false;
429        while starter >= 0 {
430            let class = combining_class(code_points[starter.cast_unsigned()]);
431            if class == 0 {
432                break;
433            }
434            if class >= last_class {
435                blocked = true;
436                break;
437            }
438            last_class = 256;
439            starter -= 1;
440        }
441
442        if !blocked && starter >= 0 {
443            let starter = starter.cast_unsigned();
444            if let Some(composed) = compose_pair(code_points[starter], code_points[input]) {
445                code_points[starter] = composed;
446                input += 1;
447                continue;
448            }
449        }
450
451        code_points[output_len] = code_points[input];
452        output_len += 1;
453        input += 1;
454    }
455    code_points.truncate(output_len);
456}
457
458fn next_code_point<U: CodeUnits + ?Sized>(input: &U, index: &mut usize) -> u32 {
459    let first = input
460        .unit_at(*index)
461        .expect("normalization advanced past its input");
462    *index += 1;
463    if (0xd800..=0xdbff).contains(&first)
464        && input
465            .unit_at(*index)
466            .is_some_and(|second| (0xdc00..=0xdfff).contains(&second))
467    {
468        let second = input
469            .unit_at(*index)
470            .expect("checked trail surrogate disappeared");
471        *index += 1;
472        0x1_0000 + ((u32::from(first) - 0xd800) << 10) + (u32::from(second) - 0xdc00)
473    } else {
474        u32::from(first)
475    }
476}
477
478/// Normalize to code points: valid surrogate pairs are decoded, lone
479/// surrogates are kept as their code units.
480pub fn normalize_code_points<U: CodeUnits + ?Sized>(
481    input: &U,
482    form: NormalizationForm,
483) -> Result<Vec<u32>, StringError> {
484    let mut output = Vec::new();
485    try_reserve_exact(&mut output, input.unit_len())?;
486
487    if form == NormalizationForm::Nfc && input.all_latin1() {
488        output.extend((0..input.unit_len()).map(|index| {
489            u32::from(
490                input
491                    .unit_at(index)
492                    .expect("Latin-1 scan stayed inside the input"),
493            )
494        }));
495        return Ok(output);
496    }
497
498    let mut index = 0;
499    while index < input.unit_len() {
500        let code_point = next_code_point(input, &mut index);
501        append_decomposed(&mut output, code_point, form.is_compatibility())?;
502    }
503    canonical_order(&mut output);
504    if !form.is_decomposed() {
505        compose(&mut output);
506    }
507    Ok(output)
508}
509
510fn string_from_code_points_with_limit(
511    code_points: &[u32],
512    max_len: usize,
513) -> Result<CodeUnitBuf, StringError> {
514    let max_len = max_len.min(MAX_STRING_LEN);
515    let mut utf16_len = 0_usize;
516    let mut wide = false;
517    for code_point in code_points {
518        debug_assert!(*code_point <= 0x10_ffff);
519        utf16_len = utf16_len
520            .checked_add(usize::from(*code_point >= 0x1_0000) + 1)
521            .filter(|length| *length <= max_len)
522            .ok_or(StringError::TooLong)?;
523        wide |= *code_point >= 0x100;
524    }
525
526    if !wide {
527        let mut output = Vec::new();
528        try_reserve_exact(&mut output, utf16_len)?;
529        output.extend(code_points.iter().map(|code_point| *code_point as u8));
530        return Ok(CodeUnitBuf::Latin1(output));
531    }
532
533    let mut output = Vec::new();
534    try_reserve_exact(&mut output, utf16_len)?;
535    for code_point in code_points {
536        if *code_point < 0x1_0000 {
537            output.push(*code_point as u16);
538        } else {
539            let adjusted = *code_point - 0x1_0000;
540            output.push(0xd800 | (adjusted >> 10) as u16);
541            output.push(0xdc00 | (adjusted & 0x3ff) as u16);
542        }
543    }
544    Ok(CodeUnitBuf::Utf16(output))
545}
546
547/// [`normalize`] with a UTF-16 output length limit (clamped to
548/// [`MAX_STRING_LEN`]).
549pub fn normalize_with_limit<U: CodeUnits + ?Sized>(
550    input: &U,
551    form: NormalizationForm,
552    max_len: usize,
553) -> Result<CodeUnitBuf, StringError> {
554    if max_len >= MAX_STRING_LEN {
555        return normalize(input, form);
556    }
557    let output = normalize_code_points(input, form)?;
558    string_from_code_points_with_limit(&output, max_len)
559}
560
561/// Normalize an ECMAScript UTF-16 string with QuickJS 2026-06-04's Unicode 17
562/// data. Valid surrogate pairs are decoded and re-encoded; lone surrogates are
563/// intentionally preserved as lone code units.
564pub fn normalize<U: CodeUnits + ?Sized>(
565    input: &U,
566    form: NormalizationForm,
567) -> Result<CodeUnitBuf, StringError> {
568    let output = normalize_code_points(input, form)?;
569    string_from_code_points_with_limit(&output, MAX_STRING_LEN)
570}
571
572#[cfg(test)]
573mod tests {
574    use super::{
575        DECOMPOSITION_MAX_LEN, NormalizationForm, combining_class, compose_pair, decomposition,
576        decomposition_entry, fail_next_normalize_reservation_for_test, normalize,
577        normalize_code_points, normalize_with_limit, push_fallible, tables,
578    };
579    use crate::unicode::{CodeUnitBuf, StringError};
580
581    fn js(text: &str) -> Vec<u16> {
582        text.encode_utf16().collect()
583    }
584
585    fn text(output: &CodeUnitBuf) -> String {
586        String::from_utf16_lossy(&output.to_utf16())
587    }
588
589    fn normalized(source: &str, form: NormalizationForm) -> String {
590        text(&normalize(&js(source)[..], form).unwrap())
591    }
592
593    #[test]
594    fn generated_tables_are_pinned_to_quickjs_unicode_17() {
595        assert_eq!(
596            tables::SOURCE_SHA256,
597            "cf782bc7a07549e976f606bd3cb8555858482b279574554dcb8d46412986006c"
598        );
599        assert_eq!(tables::UNICODE_CC_TABLE.len(), 937);
600        assert_eq!(tables::UNICODE_CC_INDEX.len(), 90);
601        assert_eq!(tables::UNICODE_DECOMP_TABLE1.len(), 709);
602        assert_eq!(tables::UNICODE_DECOMP_TABLE2.len(), 709);
603        assert_eq!(tables::UNICODE_DECOMP_DATA.len(), 9_452);
604        assert_eq!(tables::UNICODE_COMP_TABLE.len(), 965);
605    }
606
607    #[test]
608    fn canonical_and_compatibility_forms_match_quickjs_samples() {
609        assert_eq!(normalized("e\u{301}", NormalizationForm::Nfc), "é");
610        assert_eq!(normalized("é", NormalizationForm::Nfd), "e\u{301}");
611        assert_eq!(normalized("\u{fb01}", NormalizationForm::Nfc), "fi");
612        assert_eq!(normalized("\u{fb01}", NormalizationForm::Nfkc), "fi");
613        assert_eq!(normalized("\u{a0}", NormalizationForm::Nfkd), " ");
614    }
615
616    #[test]
617    fn combining_marks_are_stably_ordered_and_block_composition() {
618        assert_eq!(
619            normalized("a\u{315}\u{300}", NormalizationForm::Nfc),
620            "à\u{315}"
621        );
622        assert_eq!(
623            normalized("a\u{301}\u{300}", NormalizationForm::Nfc),
624            "á\u{300}"
625        );
626        assert_eq!(
627            normalized("\u{344}", NormalizationForm::Nfc),
628            "\u{308}\u{301}"
629        );
630        assert_eq!(combining_class(0x300), 230);
631        assert_eq!(combining_class(0x315), 232);
632        assert_eq!(combining_class(0x34f), 0);
633    }
634
635    #[test]
636    fn hangul_decomposition_and_composition_follow_quickjs_boundaries() {
637        assert_eq!(normalized("각", NormalizationForm::Nfd), "각");
638        assert_eq!(normalized("각", NormalizationForm::Nfc), "각");
639        assert_eq!(compose_pair(0x1100, 0x1161), Some(0xac00));
640        assert_eq!(compose_pair(0xac00, 0x11a8), Some(0xac01));
641        assert_eq!(compose_pair(0xac00, 0x11a7), Some(0xac00));
642    }
643
644    #[test]
645    fn valid_pairs_are_decoded_while_lone_surrogates_are_preserved() {
646        let input = [0xd800_u16, 0x65, 0x301, 0xdc00, 0xd83d, 0xde00, 0xdfff];
647        let output = normalize(&input[..], NormalizationForm::Nfc).unwrap();
648        assert_eq!(
649            output.to_utf16(),
650            [0xd800, 0xe9, 0xdc00, 0xd83d, 0xde00, 0xdfff]
651        );
652        assert_eq!(
653            normalize_code_points(&input[..], NormalizationForm::Nfc).unwrap(),
654            [0xd800, 0xe9, 0xdc00, 0x1f600, 0xdfff]
655        );
656    }
657
658    #[test]
659    fn output_uses_quickjs_narrow_or_wide_storage_from_content() {
660        let narrow = normalize(&js("plain latin-1 ÿ")[..], NormalizationForm::Nfc).unwrap();
661        assert!(matches!(narrow, CodeUnitBuf::Latin1(_)));
662
663        let narrowed = normalize(&js("\u{a0}")[..], NormalizationForm::Nfkc).unwrap();
664        assert_eq!(text(&narrowed), " ");
665        assert!(matches!(narrowed, CodeUnitBuf::Latin1(_)));
666
667        let composed_narrow = normalize(&js("e\u{301}")[..], NormalizationForm::Nfc).unwrap();
668        assert!(matches!(composed_narrow, CodeUnitBuf::Latin1(_)));
669
670        let wide = normalize(&js("a\u{304}")[..], NormalizationForm::Nfc).unwrap();
671        assert!(matches!(wide, CodeUnitBuf::Utf16(_)));
672    }
673
674    #[test]
675    fn normalization_reports_the_utf16_output_length_limit() {
676        let expands = js("\u{fd}ffi");
677        assert_eq!(
678            normalize_with_limit(&expands[..], NormalizationForm::Nfkd, 4),
679            Err(StringError::TooLong)
680        );
681        assert_eq!(
682            text(&normalize_with_limit(&expands[..], NormalizationForm::Nfkd, 5).unwrap()),
683            "y\u{301}ffi"
684        );
685
686        let astral = js("😀");
687        assert_eq!(
688            normalize_with_limit(&astral[..], NormalizationForm::Nfc, 1),
689            Err(StringError::TooLong)
690        );
691    }
692
693    #[test]
694    fn normalization_reservation_failure_is_recoverable() {
695        let input = js("e\u{301}");
696        fail_next_normalize_reservation_for_test();
697        assert_eq!(
698            normalize(&input[..], NormalizationForm::Nfc),
699            Err(StringError::OutOfMemory)
700        );
701        assert_eq!(
702            text(&normalize(&input[..], NormalizationForm::Nfc).unwrap()),
703            "é"
704        );
705    }
706
707    #[test]
708    fn fallible_output_growth_is_geometric_for_expanding_decompositions() {
709        let mut values = Vec::new();
710        let mut previous_capacity = values.capacity();
711        let mut growths = 0;
712        for value in 0..16_384 {
713            push_fallible(&mut values, value).unwrap();
714            if values.capacity() != previous_capacity {
715                assert!(
716                    values.capacity() >= previous_capacity + (previous_capacity / 2).max(1),
717                    "normalization output did not retain QuickJS-style geometric growth",
718                );
719                previous_capacity = values.capacity();
720                growths += 1;
721            }
722        }
723        assert!(growths < 32, "normalization output grew {growths} times");
724
725        let source = js(&"\u{fdFA}".repeat(1_024));
726        let output = normalize(&source[..], NormalizationForm::Nfkd).unwrap();
727        assert_eq!(output.to_utf16().len(), 18 * 1_024);
728    }
729
730    #[test]
731    fn pinned_decomposition_and_composition_samples_are_stable() {
732        let mut mapping = [0_u32; DECOMPOSITION_MAX_LEN];
733        let len = decomposition(&mut mapping, 0x1e69, false);
734        assert_eq!(&mapping[..len], [0x1e63, 0x307]);
735        let len = decomposition(&mut mapping, 0xfb03, false);
736        assert_eq!(len, 0);
737        let len = decomposition(&mut mapping, 0xfb03, true);
738        assert_eq!(&mapping[..len], [0x66, 0x66, 0x69]);
739        assert_eq!(compose_pair(0x1e63, 0x307), Some(0x1e69));
740        assert_eq!(compose_pair(0x308, 0x301), Some(0x344));
741    }
742
743    #[test]
744    fn exhaustive_table_readers_match_pinned_quickjs_c_fingerprints() {
745        use crate::unicode::tests::hash_u32;
746
747        let mut decomposition_hash = 14_695_981_039_346_656_037_u64;
748        let mut class_hash = 14_695_981_039_346_656_037_u64;
749        let mut canonical_count = 0_u32;
750        let mut compatibility_count = 0_u32;
751        let mut nonzero_class_count = 0_u32;
752        let mut mapping = [0_u32; DECOMPOSITION_MAX_LEN];
753        for code_point in 0..=0x10_ffff {
754            let class = combining_class(code_point);
755            nonzero_class_count += u32::from(class != 0);
756            class_hash = hash_u32(class_hash, code_point);
757            class_hash = hash_u32(class_hash, class);
758
759            for compatibility in [false, true] {
760                let len = decomposition(&mut mapping, code_point, compatibility);
761                if compatibility {
762                    compatibility_count += u32::from(len != 0);
763                } else {
764                    canonical_count += u32::from(len != 0);
765                }
766                decomposition_hash = hash_u32(decomposition_hash, code_point);
767                decomposition_hash = hash_u32(decomposition_hash, u32::from(compatibility));
768                decomposition_hash = hash_u32(decomposition_hash, len as u32);
769                for mapped in &mapping[..len] {
770                    decomposition_hash = hash_u32(decomposition_hash, *mapped);
771                }
772            }
773        }
774
775        assert_eq!(canonical_count, 2_081);
776        assert_eq!(compatibility_count, 5_914);
777        assert_eq!(nonzero_class_count, 968);
778        assert_eq!(decomposition_hash, 6_126_396_769_325_200_388);
779        assert_eq!(class_hash, 2_580_281_225_329_042_492);
780
781        let mut composition_hash = 14_695_981_039_346_656_037_u64;
782        for (index, decomposition_index) in tables::UNICODE_COMP_TABLE.iter().enumerate() {
783            let table_index = usize::from(decomposition_index >> 6);
784            let run_offset = u32::from(decomposition_index & 0x3f);
785            let entry = tables::UNICODE_DECOMP_TABLE1[table_index];
786            let run_start = entry >> 14;
787            let run_len = (entry >> 7) & 0x7f;
788            let run_type = (entry >> 1) & 0x3f;
789            let composed = run_start + run_offset;
790            let pair_len = decomposition_entry(
791                &mut mapping,
792                composed,
793                table_index,
794                run_start,
795                run_len,
796                run_type,
797            );
798            let actual = compose_pair(mapping[0], mapping[1]).unwrap();
799            composition_hash = hash_u32(composition_hash, index as u32);
800            composition_hash = hash_u32(composition_hash, pair_len as u32);
801            composition_hash = hash_u32(composition_hash, mapping[0]);
802            composition_hash = hash_u32(composition_hash, mapping[1]);
803            composition_hash = hash_u32(composition_hash, actual);
804        }
805        assert_eq!(composition_hash, 17_411_631_189_690_117_515);
806    }
807}