Skip to main content

mongol_norm/
normalize.rs

1//! Canonical normalization — a port of the normalize core of `mongol_norm/shaper.py`
2//! (`_canonical_for_shape`, `_unit_encode_chain`, `_unit_partition`, `_apply_velar_fem`,
3//! `_slot_position`, `_letter_position`, `normalize`, `normalize_text`).
4//!
5//! Within the table's domain `normalize` is a pure function of shape: each written unit is
6//! encoded by a context-independent, FVS-pinned `(letter, fvs)` from the per-`(position, unit)`
7//! table; every chain is verified by reshaping it in full context; a chain right after MVS takes
8//! its standalone canonical so a suffix's spelling never depends on the MVS. There is no search
9//! fallback — an uncovered shape is reported (strict) or echoed back unchanged.
10//!
11//! The remedy for a genuine table gap is to widen the table, not the runtime: the table is
12//! generated offline by `python/scripts/gen_normalize_table.py`, so extending coverage means
13//! regenerating it there and rerunning `python/scripts/gen_rust_tables.py`.
14
15use std::collections::{HashMap, HashSet};
16
17use crate::generated::enums::WrittenUnit;
18use crate::shaper::Shaper;
19use crate::tables::{Fvs, NormalizeData, Position, UnitEntry};
20use crate::unicode::is_mongolian_word_char;
21use crate::Error;
22
23/// Longest written-unit tuple a table key holds (the generator asserts `unit_enc_max_len <= 3`).
24const MAX_KEY_LEN: usize = 3;
25
26/// A fixed-capacity `(written units)` key — lookups never allocate.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28struct UnitKey {
29    len: u8,
30    units: [WrittenUnit; MAX_KEY_LEN],
31}
32
33impl UnitKey {
34    fn new(units: &[WrittenUnit]) -> UnitKey {
35        debug_assert!(!units.is_empty() && units.len() <= MAX_KEY_LEN);
36        // The padding value is arbitrary but deterministic per slice, and `len` disambiguates
37        // shorter keys from longer ones, so `Hash` and `Eq` stay consistent.
38        let mut padded = [units[0]; MAX_KEY_LEN];
39        padded[..units.len()].copy_from_slice(units);
40        UnitKey {
41            len: units.len() as u8,
42            units: padded,
43        }
44    }
45}
46
47/// `(letter code point, FVS)`.
48type Encoding = (u32, Option<Fvs>);
49
50/// The runtime form of `MNG.normalize.json`.
51pub(crate) struct NormalizeTable {
52    pub canonical_version: &'static str,
53    max_len: usize,
54    table: HashMap<(Position, UnitKey), Encoding>,
55    feminine: HashMap<(Position, UnitKey), Encoding>,
56    velar_fem_units: HashSet<WrittenUnit>,
57    masculine_cps: HashSet<u32>,
58    /// Every unit that occurs in a table key, plus the three structural tokens — the vocabulary
59    /// of `normalize_written_units` and `parse_written_units`.
60    pub known_units: HashSet<WrittenUnit>,
61    /// `known_units` as names, in Python's `(-len, name)` order — the compact-segmentation
62    /// vocabulary, built once here so `parse_written_units` never sorts per call.
63    pub sorted_vocabulary: Vec<&'static str>,
64    /// The authoritative HUD `(unit, position)` inventory.
65    pub positioned_units: HashSet<(WrittenUnit, Position)>,
66}
67
68/// The unit names of `units` in Python's `sorted(known, key=lambda u: (-len(u), u))` order.
69fn sorted_vocabulary(units: &HashSet<WrittenUnit>) -> Vec<&'static str> {
70    let mut names: Vec<&'static str> = units.iter().map(|unit| unit.as_str()).collect();
71    names.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
72    names
73}
74
75fn index_entries(entries: &'static [UnitEntry]) -> HashMap<(Position, UnitKey), Encoding> {
76    entries
77        .iter()
78        .map(|entry| {
79            (
80                (entry.position, UnitKey::new(entry.units)),
81                (entry.cp, entry.fvs),
82            )
83        })
84        .collect()
85}
86
87impl NormalizeTable {
88    pub fn new(data: &'static NormalizeData) -> NormalizeTable {
89        let mut known_units: HashSet<WrittenUnit> = data
90            .unit_table
91            .iter()
92            .flat_map(|entry| entry.units.iter().copied())
93            .collect();
94        known_units.extend([WrittenUnit::Mvs, WrittenUnit::Nirugu, WrittenUnit::Zwj]);
95        let sorted_vocabulary = sorted_vocabulary(&known_units);
96        NormalizeTable {
97            canonical_version: data.canonical_version,
98            max_len: data.unit_enc_max_len,
99            table: index_entries(data.unit_table),
100            feminine: index_entries(data.velar_fem),
101            velar_fem_units: data.velar_fem_units.iter().copied().collect(),
102            masculine_cps: data.masc_to_fem.iter().map(|(masc, _)| *masc).collect(),
103            known_units,
104            sorted_vocabulary,
105            positioned_units: data.positioned_units.iter().copied().collect(),
106        }
107    }
108
109    /// Python's monkeypatched empty table (`_unit_enc = {}`, `_unit_enc_max_len = 1`): no
110    /// encodings at all, so every chain falls back.
111    #[cfg(any(test, feature = "testing"))]
112    pub fn empty(canonical_version: &'static str) -> NormalizeTable {
113        let known_units: HashSet<WrittenUnit> =
114            [WrittenUnit::Mvs, WrittenUnit::Nirugu, WrittenUnit::Zwj]
115                .into_iter()
116                .collect();
117        NormalizeTable {
118            canonical_version,
119            max_len: 1,
120            table: HashMap::new(),
121            feminine: HashMap::new(),
122            velar_fem_units: HashSet::new(),
123            masculine_cps: HashSet::new(),
124            known_units: known_units.clone(),
125            sorted_vocabulary: sorted_vocabulary(&known_units),
126            positioned_units: HashSet::new(),
127        }
128    }
129
130    fn get(&self, position: Position, units: &[WrittenUnit]) -> Option<Encoding> {
131        self.table.get(&(position, UnitKey::new(units))).copied()
132    }
133
134    fn get_feminine(&self, position: Position, units: &[WrittenUnit]) -> Option<Encoding> {
135        self.feminine.get(&(position, UnitKey::new(units))).copied()
136    }
137}
138
139/// The character a structural shape token encodes to, verbatim (Python `_STRUCTURAL_CHARS`).
140pub(crate) fn structural_char(unit: WrittenUnit) -> Option<char> {
141    match unit {
142        WrittenUnit::Mvs => Some('\u{180E}'),
143        WrittenUnit::Nirugu => Some('\u{180A}'),
144        WrittenUnit::Zwj => Some('\u{200D}'),
145        _ => None,
146    }
147}
148
149/// Joiners force cursive connection on the adjacent letter (Python `_JOINER_TOKENS`).
150pub(crate) fn is_joiner(unit: WrittenUnit) -> bool {
151    matches!(unit, WrittenUnit::Nirugu | WrittenUnit::Zwj)
152}
153
154fn structural_text(units: &[WrittenUnit]) -> String {
155    units
156        .iter()
157        .map(|unit| structural_char(*unit).expect("structural token"))
158        .collect()
159}
160
161enum Part {
162    Structural(WrittenUnit),
163    Chain(Vec<WrittenUnit>),
164}
165
166/// Split a shape at its structural tokens (which are copied through verbatim).
167fn split_parts(shape: &[WrittenUnit]) -> Vec<Part> {
168    let mut parts = Vec::new();
169    let mut chain = Vec::new();
170    for &unit in shape {
171        if unit.is_structural() {
172            if !chain.is_empty() {
173                parts.push(Part::Chain(std::mem::take(&mut chain)));
174            }
175            parts.push(Part::Structural(unit));
176        } else {
177            chain.push(unit);
178        }
179    }
180    if !chain.is_empty() {
181        parts.push(Part::Chain(chain));
182    }
183    parts
184}
185
186/// Position of a letter spanning units `[start, start + length)` in a chain of `unit_count` units.
187pub(crate) fn slot_position(start: usize, length: usize, unit_count: usize) -> Position {
188    if start == 0 && start + length == unit_count {
189        Position::Isol
190    } else if start == 0 {
191        Position::Init
192    } else if start + length == unit_count {
193        Position::Fina
194    } else {
195        Position::Medi
196    }
197}
198
199/// Position of the `letter_index`-th letter out of `total` letters.
200fn letter_position(letter_index: usize, total: usize) -> Position {
201    if total == 1 {
202        Position::Isol
203    } else if letter_index == 0 {
204        Position::Init
205    } else if letter_index == total - 1 {
206        Position::Fina
207    } else {
208        Position::Medi
209    }
210}
211
212/// Python `_unit_partition`: single deterministic local partition + encode pass. At each index
213/// take the single unit if the table has it, else the longest multi-unit entry. Joiners on either
214/// side shift the positions as if one extra unit padded that side.
215fn unit_partition(
216    table: &NormalizeTable,
217    chain: &[WrittenUnit],
218    joined_left: bool,
219    joined_right: bool,
220) -> Option<String> {
221    let unit_count = chain.len();
222    let pad_left = usize::from(joined_left);
223    let pad_right = usize::from(joined_right);
224    let padded_count = unit_count + pad_left + pad_right;
225    let mut letters: Vec<Encoding> = Vec::new();
226    let mut unit_at: Vec<Option<WrittenUnit>> = Vec::new();
227    let mut index = 0;
228    while index < unit_count {
229        let span = table.max_len.min(unit_count - index);
230        let mut hit: Option<(Encoding, usize)> = None;
231        // 1) single unit (preferred — clean output)
232        let position = slot_position(index + pad_left, 1, padded_count);
233        if let Some(encoding) = table.get(position, &chain[index..index + 1]) {
234            hit = Some((encoding, 1));
235        }
236        // 2) else the longest available multi-unit entry (last resort)
237        if hit.is_none() {
238            for length in (2..=span).rev() {
239                let position = slot_position(index + pad_left, length, padded_count);
240                if let Some(encoding) = table.get(position, &chain[index..index + length]) {
241                    hit = Some((encoding, length));
242                    break;
243                }
244            }
245        }
246        let (encoding, length) = hit?;
247        letters.push(encoding);
248        unit_at.push((length == 1).then_some(chain[index]));
249        index += length;
250    }
251    apply_velar_fem(table, &mut letters, &unit_at, pad_left, pad_right);
252    let mut text = String::new();
253    for (cp, fvs) in letters {
254        text.push(char::from_u32(cp).expect("table code points are scalar values"));
255        if let Some(fvs) = fvs {
256            text.push(fvs.as_char());
257        }
258    }
259    Some(text)
260}
261
262/// Python `_apply_velar_fem`: switch the vowel forward-coupled to each init/medi `G`/`Gx` velar to
263/// its feminine letter (only masculine a/o/u flip; backward coupling is deliberately skipped for
264/// prefix-stability).
265fn apply_velar_fem(
266    table: &NormalizeTable,
267    letters: &mut [Encoding],
268    unit_at: &[Option<WrittenUnit>],
269    pad_left: usize,
270    pad_right: usize,
271) {
272    // `letters` and `unit_at` are pushed in lockstep by `unit_partition`, so they have equal
273    // length; iterating `unit_at` (a separate slice, so no borrow conflict with `letters`)
274    // yields exactly the indices of `letters`.
275    let total = letters.len();
276    let padded_total = total + pad_left + pad_right;
277    for (letter_index, unit) in unit_at.iter().enumerate() {
278        let Some(unit) = *unit else {
279            continue;
280        };
281        if !table.velar_fem_units.contains(&unit) {
282            continue;
283        }
284        // FORWARD coupling only (init/medi velar → following vowel). Backward coupling (fina
285        // velar → preceding vowel) is deliberately skipped: a fina velar becomes medi when a
286        // suffix is appended, flipping its coupling direction, which would make the shared-prefix
287        // vowel diverge between word B and word A. The FVS-pinned velar renders `G` regardless,
288        // so a masculine preceding vowel still round-trips — that one's prettiness is traded for
289        // prefix-stability. — see shaper.py::_apply_velar_fem
290        let position = letter_position(letter_index + pad_left, padded_total);
291        if !matches!(position, Position::Init | Position::Medi) {
292            continue;
293        }
294        let target_index = letter_index + 1;
295        if target_index >= total {
296            continue;
297        }
298        let Some(target_unit) = unit_at[target_index] else {
299            continue; // multi-unit coupled letter — leave it
300        };
301        let (cp, _) = letters[target_index];
302        if !table.masculine_cps.contains(&cp) {
303            continue; // only flip a currently-masculine vowel
304        }
305        let target_position = letter_position(target_index + pad_left, padded_total);
306        let Some(feminine) = table.get_feminine(target_position, &[target_unit]) else {
307            continue; // no round-trip-safe feminine form → leave masculine
308        };
309        letters[target_index] = feminine;
310    }
311}
312
313impl Shaper {
314    /// The normalize table.
315    pub(crate) fn table(&self) -> &NormalizeTable {
316        &self.normalize
317    }
318
319    /// Version of the canonical Unicode selection policy (`"mng-canonical/3"`). Persist it next
320    /// to stored normalized keys. Always `Some`: the `Option` remains from the locales without
321    /// a normalize table the crate used to have.
322    pub fn canonical_version(&self) -> Option<&'static str> {
323        Some(self.normalize.canonical_version)
324    }
325
326    /// Python `_canonical_for_shape`: encode a full shape (chains right-to-left so each chain is
327    /// verified with the already-encoded suffix; structural tokens copied verbatim).
328    ///
329    /// Right-to-left with the encoded suffix in hand is necessary because rules interact across
330    /// MVS: a masculine vowel after an MVS can propagate backward through the MVS and mark a g/h
331    /// in the previous chain, changing its rendering between `G` and `H`. Per-chain verification
332    /// with only the adjacent MVS is insufficient. — see shaper.py::_canonical_for_shape
333    ///
334    /// The table is fetched only when a chain has to be encoded (Python calls `_build_unit_enc`
335    /// lazily), so a structural-only shape — e.g. a lone nirugu — is copied through even for
336    /// locales without a normalize table.
337    pub(crate) fn canonical_for_shape(&self, shape: &[WrittenUnit]) -> Result<String, Error> {
338        let parts = split_parts(shape);
339        // `suffix_text` accumulates the whole result: each part is prepended as it is encoded, so
340        // after the last (leftmost) part it *is* the canonical text. (Python keeps a parallel
341        // `encoded` list and joins it at the end — this deviates from that structure on purpose.)
342        let mut suffix_text = String::new();
343        let mut suffix_target: Vec<WrittenUnit> = Vec::new();
344        for index in (0..parts.len()).rev() {
345            match &parts[index] {
346                Part::Structural(unit) => {
347                    let text = structural_char(*unit)
348                        .expect("structural token")
349                        .to_string();
350                    suffix_text.insert_str(0, &text);
351                    suffix_target.insert(0, *unit);
352                }
353                Part::Chain(body) => {
354                    let table = self.table();
355                    // Context = the full run of structural tokens right before this chain, not
356                    // just the adjacent one: an MVS behind a nirugu still matters, because
357                    // chachlag looks through nirugu. — see shaper.py::_canonical_for_shape
358                    let mut prefix_tokens: Vec<WrittenUnit> = Vec::new();
359                    let mut scan = index;
360                    while scan > 0 {
361                        scan -= 1;
362                        match &parts[scan] {
363                            Part::Structural(unit) => prefix_tokens.insert(0, *unit),
364                            Part::Chain(_) => break,
365                        }
366                    }
367                    let mut chain_canonical: Option<String> = None;
368                    // A chain directly after MVS is a suffix particle: encode it STANDALONE (drop
369                    // the MVS, normalize, re-attach). Exception: chachlag `Aa` is bare `a`.
370                    if prefix_tokens.last() == Some(&WrittenUnit::Mvs) {
371                        let candidate = if body.as_slice() == [WrittenUnit::Aa] {
372                            String::from('\u{1820}')
373                        } else {
374                            self.encode_chain_canonical(table, body, &[], "", &[])?
375                        };
376                        if !candidate.is_empty() {
377                            let prefix_text = structural_text(&prefix_tokens);
378                            let mut want = prefix_tokens.clone();
379                            want.extend_from_slice(body);
380                            want.extend_from_slice(&suffix_target);
381                            if self.shape(&format!("{prefix_text}{candidate}{suffix_text}"))?
382                                == self.without_redundant_zwj(&want)
383                            {
384                                chain_canonical = Some(candidate);
385                            }
386                        }
387                    }
388                    let chain_canonical = match chain_canonical {
389                        Some(text) => text,
390                        None => self.encode_chain_canonical(
391                            table,
392                            body,
393                            &prefix_tokens,
394                            &suffix_text,
395                            &suffix_target,
396                        )?,
397                    };
398                    suffix_text.insert_str(0, &chain_canonical);
399                    let mut target = body.clone();
400                    target.extend_from_slice(&suffix_target);
401                    suffix_target = target;
402                }
403            }
404        }
405        Ok(suffix_text)
406    }
407
408    /// Python `_encode_chain_canonical` / `_compute_chain_canonical`: the table encoding of one
409    /// chain in its structural context, or `""` on a genuine table gap.
410    fn encode_chain_canonical(
411        &self,
412        table: &NormalizeTable,
413        chain: &[WrittenUnit],
414        prefix_tokens: &[WrittenUnit],
415        suffix_text: &str,
416        suffix_target: &[WrittenUnit],
417    ) -> Result<String, Error> {
418        Ok(self
419            .unit_encode_chain(table, chain, prefix_tokens, suffix_text, suffix_target)?
420            .unwrap_or_default())
421    }
422
423    /// Python `_unit_encode_chain`: partition + encode, then verify in FULL context (the
424    /// structural prefix run and the already-encoded following chains).
425    fn unit_encode_chain(
426        &self,
427        table: &NormalizeTable,
428        chain: &[WrittenUnit],
429        prefix_tokens: &[WrittenUnit],
430        suffix_text: &str,
431        suffix_target: &[WrittenUnit],
432    ) -> Result<Option<String>, Error> {
433        let joined_left = prefix_tokens.last().is_some_and(|unit| is_joiner(*unit));
434        let joined_right = suffix_target.first().is_some_and(|unit| is_joiner(*unit));
435        let Some(text) = unit_partition(table, chain, joined_left, joined_right) else {
436            return Ok(None);
437        };
438        let prefix_text = structural_text(prefix_tokens);
439        // `verify_target` MUST include `suffix_target`: without it the non-last chains of a
440        // multi-chain word never verify, and every one of them falls back.
441        // — see shaper.py::_unit_encode_chain
442        // The check sees only part of the word (no chain to the left), so a ZWJ that the full
443        // word keeps can look redundant here — `ᠠᠯ‍᠎ᠠ` verifies its last chain as `‍᠎ᠠ`, whose
444        // ZWJ has no letter beside it. Filter the target the same way `shape` filters the text;
445        // the full-word check in `normalize_impl` still has the last word.
446        let mut verify_target = prefix_tokens.to_vec();
447        verify_target.extend_from_slice(chain);
448        verify_target.extend_from_slice(suffix_target);
449        if self.shape(&format!("{prefix_text}{text}{suffix_text}"))?
450            == self.without_redundant_zwj(&verify_target)
451        {
452            Ok(Some(text))
453        } else {
454            Ok(None)
455        }
456    }
457
458    fn normalize_impl(&self, text: &str, strict: bool) -> Result<String, Error> {
459        if text.is_empty() {
460            return Ok(String::new());
461        }
462        let target = self.shape(text)?;
463        if target.is_empty() {
464            // Only FVS marks, no letter — canonical is the empty string. (Joiners are *not*
465            // dropped here: a lone nirugu/ZWJ shapes to a structural token and round-trips.)
466            return Ok(String::new());
467        }
468        let canonical = self.canonical_for_shape(&target)?;
469        if canonical.is_empty() || self.shape(&canonical)? != target {
470            if strict {
471                return Err(Error::NormalizationFallback {
472                    text: text.to_owned(),
473                    written_units: target,
474                });
475            }
476            return Ok(text.to_owned());
477        }
478        Ok(canonical)
479    }
480
481    /// Canonical, FVS-pinned encoding of one Mongolian word: within the normalize table's domain,
482    /// `shape(x) == shape(y)` ⟹ `normalize(x) == normalize(y)`, and
483    /// `shape(normalize(x)) == shape(x)`.
484    ///
485    /// Strict (the Python default): an uncovered shape is [`Error::NormalizationFallback`].
486    /// Errors with [`Error::NonMongolianChar`] on mixed-script input — see
487    /// [`Shaper::normalize_text`].
488    pub fn normalize(&self, text: &str) -> Result<String, Error> {
489        self.normalize_impl(text, true)
490    }
491
492    /// Like [`Shaper::normalize`], but an uncovered shape returns the input unchanged
493    /// (Python `strict=False`).
494    pub fn normalize_allow_fallback(&self, text: &str) -> Result<String, Error> {
495        self.normalize_impl(text, false)
496    }
497
498    fn normalize_text_impl(&self, text: &str, strict: bool) -> Result<String, Error> {
499        if text.is_empty() {
500            return Ok(String::new());
501        }
502        let mut out = String::with_capacity(text.len());
503        let mut run = String::new();
504        let mut run_is_mongolian: Option<bool> = None;
505        for ch in text.chars() {
506            let is_mongolian = is_mongolian_word_char(ch);
507            match run_is_mongolian {
508                Some(current) if current != is_mongolian => {
509                    self.flush_run(&mut out, &run, current, strict)?;
510                    run.clear();
511                    run_is_mongolian = Some(is_mongolian);
512                }
513                Some(_) => {}
514                None => run_is_mongolian = Some(is_mongolian),
515            }
516            run.push(ch);
517        }
518        if let Some(current) = run_is_mongolian {
519            self.flush_run(&mut out, &run, current, strict)?;
520        }
521        Ok(out)
522    }
523
524    fn flush_run(
525        &self,
526        out: &mut String,
527        run: &str,
528        is_mongolian: bool,
529        strict: bool,
530    ) -> Result<(), Error> {
531        if is_mongolian {
532            out.push_str(&self.normalize_impl(run, strict)?);
533        } else {
534            out.push_str(run);
535        }
536        Ok(())
537    }
538
539    /// Normalize free-form text: every Mongolian word run is normalized independently, everything
540    /// else (spaces, punctuation, Latin, …) is copied verbatim. Strict like [`Shaper::normalize`].
541    pub fn normalize_text(&self, text: &str) -> Result<String, Error> {
542        self.normalize_text_impl(text, true)
543    }
544
545    /// Like [`Shaper::normalize_text`], but an uncovered word is preserved unchanged.
546    pub fn normalize_text_allow_fallback(&self, text: &str) -> Result<String, Error> {
547        self.normalize_text_impl(text, false)
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::Locale;
555
556    const SAIN: &str = "\u{1830}\u{1820}\u{1822}\u{1828}";
557
558    #[test]
559    fn strict_mode_raises_when_canonicalization_falls_back() {
560        let shaper = Shaper::with_empty_normalize_table(Locale::Mng);
561        let error = shaper.normalize(SAIN).unwrap_err();
562        assert_eq!(
563            error,
564            Error::NormalizationFallback {
565                text: SAIN.to_owned(),
566                written_units: vec![
567                    WrittenUnit::S,
568                    WrittenUnit::A,
569                    WrittenUnit::I,
570                    WrittenUnit::I,
571                    WrittenUnit::A
572                ],
573            }
574        );
575        assert_eq!(
576            error.to_string(),
577            "normalization fallback: no canonical encoding for written units S+A+I+I+A"
578        );
579    }
580
581    #[test]
582    fn allow_fallback_preserves_input_when_canonicalization_falls_back() {
583        let shaper = Shaper::with_empty_normalize_table(Locale::Mng);
584        assert_eq!(shaper.normalize_allow_fallback(SAIN).unwrap(), SAIN);
585    }
586
587    #[test]
588    fn strict_mode_reports_a_fallback_inside_mixed_text() {
589        let shaper = Shaper::with_empty_normalize_table(Locale::Mng);
590        let text = format!("Hello {SAIN} world");
591        assert!(matches!(
592            shaper.normalize_text(&text),
593            Err(Error::NormalizationFallback { .. })
594        ));
595    }
596
597    #[test]
598    fn allow_fallback_preserves_a_fallback_inside_mixed_text() {
599        let shaper = Shaper::with_empty_normalize_table(Locale::Mng);
600        let text = format!("Hello {SAIN} world");
601        assert_eq!(shaper.normalize_text_allow_fallback(&text).unwrap(), text);
602    }
603
604    #[test]
605    fn empty_and_structural_shapes_normalize_without_a_letter() {
606        let shaper = Shaper::new(Locale::Mng);
607        assert_eq!(shaper.canonical_version(), Some("mng-canonical/3"));
608        assert_eq!(shaper.normalize("").unwrap(), "");
609        assert_eq!(shaper.normalize("\u{180B}").unwrap(), ""); // FVS only: empty shape short-circuits
610        assert_eq!(shaper.normalize("\u{180A}").unwrap(), "\u{180A}"); // structural-only shape
611    }
612
613    #[test]
614    fn positions_of_partition_slots_and_letters() {
615        assert_eq!(slot_position(0, 1, 1), Position::Isol);
616        assert_eq!(slot_position(0, 2, 2), Position::Isol);
617        assert_eq!(slot_position(0, 1, 3), Position::Init);
618        assert_eq!(slot_position(1, 1, 3), Position::Medi);
619        assert_eq!(slot_position(1, 2, 3), Position::Fina);
620        assert_eq!(letter_position(0, 1), Position::Isol);
621        assert_eq!(letter_position(0, 2), Position::Init);
622        assert_eq!(letter_position(1, 2), Position::Fina);
623        assert_eq!(letter_position(1, 3), Position::Medi);
624        assert_eq!(
625            UnitKey::new(&[WrittenUnit::A]),
626            UnitKey::new(&[WrittenUnit::A])
627        );
628        assert_ne!(
629            UnitKey::new(&[WrittenUnit::A]),
630            UnitKey::new(&[WrittenUnit::A, WrittenUnit::A])
631        );
632    }
633}