Skip to main content

mongol_norm/
shaper.rs

1//! The shaper: data indexes, written-unit resolution, the context helpers the rules use, and the
2//! public shaping entry points (`shape`, `same_shape`, `shape_detailed`, `trace`).
3use std::collections::{HashMap, HashSet};
4
5use crate::duplicates::collapse;
6use crate::generated::enums::{Alias, Condition, WrittenUnit};
7use crate::generated::mng;
8use crate::generated::mng_normalize;
9use crate::normalize::NormalizeTable;
10use crate::redundant_zwj::drop_redundant_zwj;
11use crate::rules::{self, Rule};
12use crate::tables::{Fvs, Letter, Locale, LocaleData, ParticleSym, Position, Variant};
13use crate::token::{assign_positions, tokenize, Token, TokenKind};
14use crate::unicode::check_word_chars;
15use crate::Error;
16
17/// Per-token breakdown returned by [`Shaper::shape_detailed`].
18#[derive(Clone, Debug, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct TokenDetail {
21    /// The token's code point (MVS for NNBSP input).
22    pub cp: char,
23    /// The letter's alias in this locale, if any (`None` for structural tokens).
24    pub alias: Option<Alias>,
25    /// Structural position (`Isol` for structural tokens).
26    pub position: Position,
27    /// The first FVS attached to the letter or MVS (Python reports only the first one).
28    pub fvs: Option<Fvs>,
29    /// The condition assigned by the rule pipeline.
30    pub condition: Option<Condition>,
31    /// The resolved written units (empty for structural tokens).
32    pub written: Vec<WrittenUnit>,
33}
34
35/// One condition change recorded by [`Shaper::trace`].
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct ConditionChange {
39    /// Token index.
40    pub token: usize,
41    /// Condition before the rule ran.
42    pub before: Option<Condition>,
43    /// Condition after the rule ran.
44    pub after: Option<Condition>,
45}
46
47/// The changes one rule made, in [`Shaper::trace`].
48#[derive(Clone, Debug, PartialEq, Eq)]
49#[non_exhaustive]
50pub struct RuleTransition {
51    /// The rule name (see [`Shaper::rule_names`]).
52    pub rule: &'static str,
53    /// Every token whose condition changed, in token order.
54    pub changes: Vec<ConditionChange>,
55}
56
57/// A per-rule trace of the shaping pipeline — the verifier for
58/// `tests/golden/mng-phase-trace-v1.json`. Every vector covers *all* tokens, structural ones
59/// included (position `Isol`, condition `None`, no written units).
60#[derive(Clone, Debug, PartialEq, Eq)]
61#[non_exhaustive]
62pub struct ShapeTrace {
63    /// Position of every token.
64    pub positions: Vec<Position>,
65    /// Rules that changed at least one condition, in rule order.
66    pub transitions: Vec<RuleTransition>,
67    /// Condition of every token after all rules ran.
68    pub final_conditions: Vec<Option<Condition>>,
69    /// Resolved written units of every token.
70    pub written_by_token: Vec<Vec<WrittenUnit>>,
71    /// The flattened shape (what [`Shaper::shape`] returns).
72    pub shape: Vec<WrittenUnit>,
73}
74
75/// The UTN #57 shaping engine and the canonical normalizer for Hudum ([`Locale::Mng`], the only
76/// locale).
77///
78/// Construction is cheap (microseconds) and the value is `Send + Sync`, so one instance can be
79/// shared behind a reference for the lifetime of a program.
80pub struct Shaper {
81    locale: Locale,
82    letters: HashMap<u32, &'static Letter>,
83    variants: HashMap<(u32, Position, Option<Fvs>), &'static Variant>,
84    defaults: HashMap<(u32, Position), &'static Variant>,
85    vowels: HashSet<Alias>,
86    consonants: HashSet<Alias>,
87    masculine: HashSet<Alias>,
88    feminine: HashSet<Alias>,
89    neuter: HashSet<Alias>,
90    particles: HashMap<&'static [ParticleSym], &'static [usize]>,
91    rules: &'static [Rule],
92    pub(crate) normalize: NormalizeTable,
93}
94
95/// `Shaper` holds only owned tables and `&'static` data, so sharing one behind a reference
96/// across threads is sound; this pins that claim at compile time.
97const _: () = {
98    fn assert_send_sync<T: Send + Sync>() {}
99    let _ = assert_send_sync::<Shaper>;
100};
101
102impl Default for Shaper {
103    fn default() -> Shaper {
104        Shaper::new(Locale::Mng)
105    }
106}
107
108impl std::fmt::Debug for Shaper {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("Shaper")
111            .field("locale", &self.locale)
112            .field("letters", &self.letters.len())
113            .field("variants", &self.variants.len())
114            .field("rules", &self.rules.len())
115            .finish_non_exhaustive()
116    }
117}
118
119impl Shaper {
120    /// Build the shaper for `locale` from the generated tables.
121    pub fn new(locale: Locale) -> Shaper {
122        let (data, normalize): (&'static LocaleData, _) = match locale {
123            Locale::Mng => (&mng::DATA, &mng_normalize::DATA),
124        };
125        let mut letters = HashMap::new();
126        let mut variants = HashMap::new();
127        let mut defaults = HashMap::new();
128        for letter in data.letters {
129            letters.insert(letter.cp, letter);
130            for variant in letter.variants {
131                variants.insert((letter.cp, variant.position, variant.fvs), variant);
132                if variant.default {
133                    defaults.insert((letter.cp, variant.position), variant);
134                }
135            }
136        }
137        let set = |aliases: &'static [Alias]| aliases.iter().copied().collect::<HashSet<Alias>>();
138        Shaper {
139            locale,
140            letters,
141            variants,
142            defaults,
143            vowels: set(data.categories.vowel),
144            consonants: set(data.categories.consonant),
145            masculine: set(data.categories.vowel_masculine),
146            feminine: set(data.categories.vowel_feminine),
147            neuter: set(data.categories.vowel_neuter),
148            particles: data
149                .particles
150                .iter()
151                .map(|particle| (particle.key, particle.indices))
152                .collect(),
153            rules: rules::rules_for(locale),
154            normalize: NormalizeTable::new(normalize),
155        }
156    }
157
158    /// Python's monkeypatched empty normalize table (`python/tests/test_shaper.py`,
159    /// `python/tests/test_cli.py`): every chain falls back. No reachable MNG input misses the real
160    /// table, so the fallback paths are only testable this way.
161    #[cfg(any(test, feature = "testing"))]
162    #[doc(hidden)]
163    pub fn with_empty_normalize_table(locale: Locale) -> Shaper {
164        let mut shaper = Shaper::new(locale);
165        shaper.normalize = NormalizeTable::empty(shaper.normalize.canonical_version);
166        shaper
167    }
168
169    /// The locale this shaper was built for.
170    pub fn locale(&self) -> Locale {
171        self.locale
172    }
173
174    /// The names of the shaping rules, in the order they run.
175    pub fn rule_names(&self) -> Vec<&'static str> {
176        self.rules.iter().map(|rule| rule.name).collect()
177    }
178
179    // ── data access ─────────────────────────────────────────────────────────────────────────
180
181    pub(crate) fn alias_of(&self, cp: u32) -> Option<Alias> {
182        self.letters.get(&cp).map(|letter| letter.alias)
183    }
184
185    pub(crate) fn tokenize(&self, text: &str) -> Vec<Token> {
186        tokenize(text, |cp| self.alias_of(cp))
187    }
188
189    /// Particle-dictionary lookup by exact symbol sequence.
190    pub(crate) fn particle(&self, key: &[ParticleSym]) -> Option<&'static [usize]> {
191        self.particles.get(key).copied()
192    }
193
194    /// Python `_get_condition_fvs`: the FVS of the first variant (in table order) at `position`
195    /// whose conditions include `condition`.
196    ///
197    /// `None` means no variant at `position` carries `condition`; `Some(None)` means the bare
198    /// (FVS-less) variant carries it — Python's FVS `0`.
199    fn condition_fvs(
200        &self,
201        cp: u32,
202        position: Position,
203        condition: Condition,
204    ) -> Option<Option<Fvs>> {
205        self.letters
206            .get(&cp)?
207            .variants
208            .iter()
209            .find(|variant| variant.position == position && variant.conditions.contains(&condition))
210            .map(|variant| variant.fvs)
211    }
212
213    /// Python `_resolve_token_written` (memoised on the token):
214    /// 1. the variant the FVS right after the letter names, if it names one,
215    /// 2. else the variant carrying the rule-assigned condition,
216    /// 3. else the default variant. Structural tokens resolve to nothing.
217    ///
218    /// Only the first FVS of a run can select: GB/T 25914-2023 counts a repeated FVS as invalid
219    /// ("重复使用了规定的自由变体选择符"), EAC XIM11-662 shows `sh fvs3 fvs1` with both FVSes as
220    /// error glyphs although FVS1 alone would be valid, and the font binds only the adjacent one.
221    /// So `i fvs4 fvs1` is a plain `i`, not FVS1's variant.
222    ///
223    /// The result is memoised on the token and never invalidated: a rule that changes an
224    /// already-resolved token's condition has no effect on its written units (Python parity —
225    /// III.4 and III.5 resolve the *previous* token mid-pipeline, freezing it).
226    pub(crate) fn resolve_written(&self, token: &mut Token) {
227        if token.written.is_some() {
228            return;
229        }
230        if !token.is_letter() {
231            token.written = Some(&[]);
232            return;
233        }
234        let written = token
235            .first_fvs()
236            .and_then(|fvs| self.variants.get(&(token.cp, token.position, Some(fvs))))
237            .map(|variant| variant.written)
238            .unwrap_or_else(|| self.contextual_written(token));
239        token.written = Some(written);
240    }
241
242    /// The written units of a letter before any FVS applies: those of its condition, else its
243    /// default. The font's phase III rules see this form, since the font applies an FVS only at
244    /// the end (`III.fvs.MNG`).
245    pub(crate) fn contextual_written(&self, token: &Token) -> &'static [WrittenUnit] {
246        token
247            .condition
248            .and_then(|condition| self.condition_fvs(token.cp, token.position, condition))
249            .and_then(|fvs| self.variants.get(&(token.cp, token.position, fvs)))
250            .or_else(|| self.defaults.get(&(token.cp, token.position)))
251            .map_or(&[], |variant| variant.written)
252    }
253
254    // ── predicates used by the rules ────────────────────────────────────────────────────────
255
256    pub(crate) fn is_vowel(&self, token: &Token) -> bool {
257        token.is_letter()
258            && token
259                .alias
260                .is_some_and(|alias| self.vowels.contains(&alias))
261    }
262
263    pub(crate) fn is_consonant(&self, token: &Token) -> bool {
264        token.is_letter()
265            && token
266                .alias
267                .is_some_and(|alias| self.consonants.contains(&alias))
268    }
269
270    /// The FVS right after the letter selects a variant at its position — the one
271    /// [`Shaper::resolve_written`] takes (any further FVS is a repeat and selects nothing). An
272    /// FVS that selects nothing locks nothing: the font carries it through phase III as
273    /// `fvsN.ignored`, a mark the context lookups skip.
274    pub(crate) fn has_valid_fvs(&self, token: &Token) -> bool {
275        token.is_letter()
276            && token.first_fvs().is_some_and(|fvs| {
277                self.variants
278                    .contains_key(&(token.cp, token.position, Some(fvs)))
279            })
280    }
281
282    pub(crate) fn is_masc_vowel(&self, token: &Token) -> bool {
283        token
284            .alias
285            .is_some_and(|alias| self.masculine.contains(&alias))
286    }
287
288    pub(crate) fn is_fem_vowel(&self, token: &Token) -> bool {
289        token
290            .alias
291            .is_some_and(|alias| self.feminine.contains(&alias))
292    }
293
294    pub(crate) fn is_neut_vowel(&self, token: &Token) -> bool {
295        token
296            .alias
297            .is_some_and(|alias| self.neuter.contains(&alias))
298    }
299
300    /// Python `_masc_marker_reaches_g_h`: would mongfontbuilder's MASC marker sit immediately
301    /// after the g/h at `idx` after the full preprocessing chain? Forward scan
302    /// (preprocessing.A/B/C) then backward scan (preprocessing.G/H/J/K). Nirugu is transparent,
303    /// MVS blocks.
304    pub(crate) fn masc_marker_reaches_g_h(&self, tokens: &[Token], idx: usize) -> bool {
305        // ── Forward (preprocessing.A/B/C) ──
306        let mut j = idx;
307        while j > 0 {
308            j -= 1;
309            let token = &tokens[j];
310            if !token.is_letter() {
311                if token.is_nirugu() {
312                    continue;
313                }
314                break; // mvs blocks; fall through to the backward check
315            }
316            if self.is_fem_vowel(token) {
317                break;
318            }
319            if self.is_masc_vowel(token)
320                && matches!(token.position, Position::Init | Position::Medi)
321            {
322                return true;
323            }
324        }
325
326        // ── Backward (preprocessing.G/H/J/K) ──
327        if !matches!(tokens[idx].position, Position::Init | Position::Medi) {
328            return false;
329        }
330        // A fem vowel earlier in the word blocks the backward chain.
331        let mut j = idx;
332        while j > 0 {
333            j -= 1;
334            let token = &tokens[j];
335            if !token.is_letter() {
336                if token.is_nirugu() {
337                    continue;
338                }
339                break;
340            }
341            if self.is_fem_vowel(token) {
342                return false;
343            }
344        }
345        // Walk forward through an unbroken chain of non-fem init/medi letters, terminating at
346        // a masc vowel or at `fina letter + mvs + isol a`.
347        let mut j = idx + 1;
348        while j < tokens.len() {
349            let next = &tokens[j];
350            if !next.is_letter() {
351                if next.is_nirugu() {
352                    j += 1;
353                    continue;
354                }
355                return false;
356            }
357            if self.is_masc_vowel(next) {
358                return true;
359            }
360            if self.is_fem_vowel(next) {
361                return false;
362            }
363            if matches!(next.position, Position::Init | Position::Medi) {
364                j += 1;
365                continue;
366            }
367            let mut k = j + 1;
368            while k < tokens.len() && tokens[k].is_nirugu() {
369                k += 1;
370            }
371            return next.position == Position::Fina
372                && k < tokens.len()
373                && tokens[k].is_mvs()
374                && k + 1 < tokens.len()
375                && tokens[k + 1].is_letter()
376                && tokens[k + 1].alias == Some(Alias::A)
377                && tokens[k + 1].position == Position::Isol;
378        }
379        false
380    }
381
382    // ── shaping ─────────────────────────────────────────────────────────────────────────────
383
384    /// The prologue shared by [`Shaper::shape`] and [`Shaper::trace`]: validate, tokenize,
385    /// assign positions.
386    fn prepare(&self, text: &str) -> Result<Vec<Token>, Error> {
387        check_word_chars(text)?;
388        let mut tokens = self.tokenize(text);
389        assign_positions(&mut tokens);
390        Ok(tokens)
391    }
392
393    /// Resolve every token's written units (idempotent — the rules may have resolved some).
394    fn resolve_all(&self, tokens: &mut [Token]) {
395        for token in tokens {
396            self.resolve_written(token);
397        }
398    }
399
400    fn run_pipeline(&self, text: &str) -> Result<Vec<Token>, Error> {
401        let mut tokens = self.prepare(text)?;
402        rules::run_rules(self.rules, &mut tokens, self);
403        self.resolve_all(&mut tokens);
404        Ok(tokens)
405    }
406
407    /// Shape `text` into its written-unit sequence. MVS and nirugu appear verbatim as
408    /// [`WrittenUnit::Mvs`] and [`WrittenUnit::Nirugu`]; a ZWJ appears as [`WrittenUnit::Zwj`]
409    /// only where it changes the ink — between two letters that already join, next to a nirugu,
410    /// doubled, or at a word edge whose joined and unjoined forms are the same shape, it is
411    /// dropped (see `redundant_zwj.rs` for the rules and the reviewed edge table). An MVS is a
412    /// segment edge, so a ZWJ next to it is judged like one at the word edge.
413    ///
414    /// Nine written units render as exactly the same ink as a sequence of other units, and each
415    /// is unified with that sequence here. Five expand — `Dd` (both positions), medial `H`,
416    /// medial `Hx` and initial `Cr` come out as `O A`, `A A`, `N N` and `O O`. The other four
417    /// contract, because their expansion ends in `Aa`, which is itself a duplicate: a chain-final
418    /// `A Aa` becomes `Aa` (or `A` when it is the whole chain), `O Aa` becomes `B2` and `I Aa`
419    /// becomes `G`. That is what makes `shape` a fingerprint of the visible word — ᠠᠷᠠᠳ and ᠠᠷᠠᠤᠠ
420    /// are one word and shape identically. The standard's own unit sequence, which keeps all
421    /// nine apart, is [`Shaper::shape_raw`]. `docs/internals.md` ("Duplicate encodings") has the
422    /// evidence and the termination argument.
423    ///
424    /// Errors with [`Error::NonMongolianChar`] on anything but Mongolian letters, FVS, MVS,
425    /// NNBSP, nirugu and ZWJ — use [`Shaper::normalize_text`] for mixed-script text.
426    pub fn shape(&self, text: &str) -> Result<Vec<WrittenUnit>, Error> {
427        Ok(self.canonical_units(&self.shape_raw(text)?))
428    }
429
430    /// The public shape of an engine-level unit sequence: redundant ZWJs dropped, then the
431    /// duplicate encodings unified — in that order, so a joiner that separated `I Aa` no longer
432    /// blocks the `G` contraction. Shared by [`Shaper::shape`], [`Shaper::trace`] and the
433    /// written-unit encoders, which is what keeps `normalize_written_units([L, I, Zwj, Aa])` and
434    /// `normalize("ᠯᠢ\u{200D}ᠡ᠋")` on one contract.
435    pub(crate) fn canonical_units(&self, raw: &[WrittenUnit]) -> Vec<WrittenUnit> {
436        collapse(&self.without_redundant_zwj(raw))
437    }
438
439    /// The redundant-ZWJ filter alone (no duplicate unification).
440    pub(crate) fn without_redundant_zwj(&self, units: &[WrittenUnit]) -> Vec<WrittenUnit> {
441        drop_redundant_zwj(units)
442    }
443
444    /// The engine's written-unit sequence before redundant ZWJs are dropped and duplicate
445    /// encodings are unified — the sequence UTN #57 / GB/T 25914-2023 describe, and the one
446    /// their EAC conformance vectors are checked against. All nine duplicates and every ZWJ of
447    /// the input can appear here.
448    ///
449    /// Not part of the public contract: it exists so the conformance suites and the table
450    /// generator can compare against the standard verbatim. Everything user-facing goes through
451    /// [`Shaper::shape`].
452    #[doc(hidden)]
453    pub fn shape_raw(&self, text: &str) -> Result<Vec<WrittenUnit>, Error> {
454        Ok(flatten(&self.run_pipeline(text)?))
455    }
456
457    /// [`Shaper::shape`] joined with `+` (`S+A+I+I+A`), the CLI's output format.
458    pub fn shape_str(&self, text: &str) -> Result<String, Error> {
459        let units = self.shape(text)?;
460        let mut out = String::new();
461        for unit in units {
462            if !out.is_empty() {
463                out.push('+');
464            }
465            out.push_str(unit.as_str());
466        }
467        Ok(out)
468    }
469
470    /// Do `a` and `b` render the same glyph sequence?
471    pub fn same_shape(&self, a: &str, b: &str) -> Result<bool, Error> {
472        Ok(self.shape(a)? == self.shape(b)?)
473    }
474
475    /// Per-token shaping breakdown (Python `shape_detailed`).
476    pub fn shape_detailed(&self, text: &str) -> Result<Vec<TokenDetail>, Error> {
477        let tokens = self.run_pipeline(text)?;
478        Ok(tokens
479            .iter()
480            .map(|token| TokenDetail {
481                cp: char::from_u32(token.cp).expect("token code points are scalar values"),
482                alias: token.alias,
483                position: token.position,
484                fvs: token.first_fvs(),
485                condition: token.condition,
486                written: token
487                    .written
488                    .map(<[WrittenUnit]>::to_vec)
489                    .unwrap_or_default(),
490            })
491            .collect())
492    }
493
494    /// Run the pipeline one rule at a time and record every condition change.
495    pub fn trace(&self, text: &str) -> Result<ShapeTrace, Error> {
496        let mut tokens = self.prepare(text)?;
497        let mut transitions = Vec::new();
498        for rule in self.rules {
499            let before: Vec<Option<Condition>> =
500                tokens.iter().map(|token| token.condition).collect();
501            (rule.apply)(&mut tokens, self);
502            let changes: Vec<ConditionChange> = before
503                .iter()
504                .zip(&tokens)
505                .enumerate()
506                .filter(|(_, (old, token))| **old != token.condition)
507                .map(|(index, (old, token))| ConditionChange {
508                    token: index,
509                    before: *old,
510                    after: token.condition,
511                })
512                .collect();
513            if !changes.is_empty() {
514                transitions.push(RuleTransition {
515                    rule: rule.name,
516                    changes,
517                });
518            }
519        }
520        self.resolve_all(&mut tokens);
521        Ok(ShapeTrace {
522            positions: tokens.iter().map(|token| token.position).collect(),
523            transitions,
524            final_conditions: tokens.iter().map(|token| token.condition).collect(),
525            written_by_token: tokens
526                .iter()
527                .map(|token| {
528                    token
529                        .written
530                        .map(<[WrittenUnit]>::to_vec)
531                        .unwrap_or_default()
532                })
533                .collect(),
534            shape: self.canonical_units(&flatten(&tokens)),
535        })
536    }
537}
538
539/// Flatten resolved tokens into the public shape.
540pub(crate) fn flatten(tokens: &[Token]) -> Vec<WrittenUnit> {
541    let mut shape = Vec::with_capacity(tokens.len() * 2);
542    for token in tokens {
543        match token.kind {
544            TokenKind::Mvs => shape.push(WrittenUnit::Mvs),
545            TokenKind::Nirugu => shape.push(WrittenUnit::Nirugu),
546            TokenKind::Zwj => shape.push(WrittenUnit::Zwj),
547            TokenKind::Letter => {
548                if let Some(written) = token.written {
549                    shape.extend_from_slice(written);
550                }
551            }
552        }
553    }
554    shape
555}
556
557// ── context helpers shared with the rules (Python `_prev_letter` & co.), returning indices ──
558
559/// The token right before `index`. (`_tokens` is unused; it keeps the signature symmetric with
560/// the other context helpers.)
561pub(crate) fn prev_tok(_tokens: &[Token], index: usize) -> Option<usize> {
562    if index > 0 {
563        Some(index - 1)
564    } else {
565        None
566    }
567}
568
569/// The token right after `index`.
570pub(crate) fn next_tok(tokens: &[Token], index: usize) -> Option<usize> {
571    if index + 1 < tokens.len() {
572        Some(index + 1)
573    } else {
574        None
575    }
576}
577
578/// Nearest preceding letter reached without crossing an MVS; nirugu is transparent.
579pub(crate) fn prev_adjacent_letter(tokens: &[Token], index: usize) -> Option<usize> {
580    let mut j = index;
581    while j > 0 {
582        j -= 1;
583        let token = &tokens[j];
584        if token.is_mvs() {
585            return None;
586        }
587        if token.is_letter() {
588            return Some(j);
589        }
590        if token.is_nirugu() {
591            continue;
592        }
593        return None;
594    }
595    None
596}
597
598/// Mirror image of [`prev_adjacent_letter`].
599pub(crate) fn next_adjacent_letter(tokens: &[Token], index: usize) -> Option<usize> {
600    let mut j = index + 1;
601    while j < tokens.len() {
602        let token = &tokens[j];
603        if token.is_mvs() {
604            return None;
605        }
606        if token.is_letter() {
607            return Some(j);
608        }
609        if token.is_nirugu() {
610            j += 1;
611            continue;
612        }
613        return None;
614    }
615    None
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn default_forms_without_rules() {
624        let shaper = Shaper::new(Locale::Mng);
625        assert_eq!(
626            shaper.shape("\u{1820}").unwrap(),
627            vec![WrittenUnit::A, WrittenUnit::A]
628        );
629        assert_eq!(
630            shaper.shape("\u{1820}\u{180B}").unwrap(),
631            vec![WrittenUnit::A]
632        );
633        assert_eq!(shaper.shape("\u{180E}").unwrap(), vec![WrittenUnit::Mvs]);
634        assert_eq!(
635            shaper.shape("\u{180A}\u{1823}").unwrap(),
636            vec![WrittenUnit::Nirugu, WrittenUnit::U]
637        );
638        // The engine keeps `Dd` and the ZWJ; the public shape drops the joiner (a lone `Dd:fina`
639        // is the same shape as `Dd:isol`) and folds the duplicate: `O A`.
640        assert_eq!(
641            shaper.shape_raw("\u{200D}\u{1833}").unwrap(),
642            vec![WrittenUnit::Zwj, WrittenUnit::Dd]
643        );
644        assert_eq!(
645            shaper.shape("\u{200D}\u{1833}").unwrap(),
646            vec![WrittenUnit::O, WrittenUnit::A]
647        );
648        assert_eq!(shaper.shape("").unwrap(), Vec::<WrittenUnit>::new());
649        assert_eq!(shaper.shape_str("\u{1820}").unwrap(), "A+A");
650        assert!(shaper.same_shape("\u{1820}", "\u{1820}").unwrap());
651        assert_eq!(
652            shaper.shape("\u{1820} "),
653            Err(Error::NonMongolianChar { ch: ' ', index: 1 })
654        );
655    }
656
657    #[test]
658    fn detailed_and_trace_cover_every_token() {
659        let shaper = Shaper::new(Locale::Mng);
660        let details = shaper
661            .shape_detailed("\u{1832}\u{1820}\u{182F}\u{202F}\u{1820}")
662            .unwrap();
663        let positions: Vec<Position> = details.iter().map(|d| d.position).collect();
664        assert_eq!(
665            positions,
666            vec![
667                Position::Init,
668                Position::Medi,
669                Position::Fina,
670                Position::Isol,
671                Position::Isol
672            ]
673        );
674        assert_eq!(details[3].cp, '\u{180E}'); // NNBSP folded into MVS
675        assert_eq!(details[3].written, Vec::<WrittenUnit>::new());
676        assert_eq!(details[0].alias, Some(Alias::T));
677        let trace = shaper.trace("\u{1820}").unwrap();
678        assert_eq!(trace.positions, vec![Position::Isol]);
679        assert!(trace.transitions.is_empty());
680        assert_eq!(trace.final_conditions, vec![None]);
681        assert_eq!(
682            trace.written_by_token,
683            vec![vec![WrittenUnit::A, WrittenUnit::A]]
684        );
685        assert_eq!(trace.shape, shaper.shape("\u{1820}").unwrap());
686    }
687
688    /// Tokenize `text` and assign positions, ready for the context helpers.
689    fn prepared(shaper: &Shaper, text: &str) -> Vec<Token> {
690        let mut tokens = shaper.tokenize(text);
691        assign_positions(&mut tokens);
692        tokens
693    }
694
695    /// One letter token at `position` carrying `condition`, resolved.
696    fn resolved(
697        shaper: &Shaper,
698        cp: u32,
699        position: Position,
700        condition: Option<Condition>,
701    ) -> Token {
702        let text = char::from_u32(cp).expect("scalar value").to_string();
703        let mut token = shaper.tokenize(&text).remove(0);
704        token.position = position;
705        token.condition = condition;
706        shaper.resolve_written(&mut token);
707        token
708    }
709
710    #[test]
711    fn masc_marker_reaches_g_h_paths() {
712        let shaper = Shaper::new(Locale::Mng);
713        // Every expectation below was cross-checked against Python's
714        // `MongolianShaper('MNG')._masc_marker_reaches_g_h`.
715
716        // (a) Forward (preprocessing.A/B/C): a masc vowel at init/medi before the g/h.
717        // `a l g` (ᠠᠯᠭ) — a.init is masculine, so the marker reaches g.fina.
718        let tokens = prepared(&shaper, "\u{1820}\u{182F}\u{182D}");
719        assert!(shaper.masc_marker_reaches_g_h(&tokens, 2));
720
721        // (b) Backward (preprocessing.G/H/J/K), the witness from the Python docstring:
722        // `s i g s i g a` (ᠰᠢᠭᠰᠢᠭᠠ) — the first g reaches the trailing masc `a` through
723        // the unbroken init/medi chain g→s→i→g.
724        let tokens = prepared(
725            &shaper,
726            "\u{1830}\u{1822}\u{182D}\u{1830}\u{1822}\u{182D}\u{1820}",
727        );
728        assert!(shaper.masc_marker_reaches_g_h(&tokens, 2));
729
730        // (c) A feminine vowel blocks both directions.
731        let tokens = prepared(&shaper, "\u{1821}\u{182F}\u{182D}"); // `e l g`
732        assert!(!shaper.masc_marker_reaches_g_h(&tokens, 2));
733        // `e g e n i g t a` — the fem `e` earlier in the word blocks the second g.
734        let tokens = prepared(
735            &shaper,
736            "\u{1821}\u{182D}\u{1821}\u{1828}\u{1822}\u{182D}\u{1832}\u{1820}",
737        );
738        assert!(!shaper.masc_marker_reaches_g_h(&tokens, 5));
739
740        // (d) The `fina letter + mvs + isol a` terminator of the backward walk:
741        // `i g l mvs a` (ᠢᠭᠯ᠎ᠠ) — g.medi, then l.fina, MVS, isolated `a`.
742        let tokens = prepared(&shaper, "\u{1822}\u{182D}\u{182F}\u{180E}\u{1820}");
743        assert!(shaper.masc_marker_reaches_g_h(&tokens, 1));
744        // … and `e` in place of the `a` is not the terminator.
745        let tokens = prepared(&shaper, "\u{1822}\u{182D}\u{182F}\u{180E}\u{1821}");
746        assert!(!shaper.masc_marker_reaches_g_h(&tokens, 1));
747    }
748
749    #[test]
750    fn resolve_written_condition_branch() {
751        let shaper = Shaper::new(Locale::Mng);
752
753        // (a) The bare (FVS-less) h.fina variant carries `chachlag_onset` and
754        // `masculine_devsger`, so `condition_fvs` reports `Some(None)` (Python FVS 0).
755        assert_eq!(
756            shaper.condition_fvs(0x182C, Position::Fina, Condition::MasculineDevsger),
757            Some(None)
758        );
759        let token = resolved(
760            &shaper,
761            0x182C,
762            Position::Fina,
763            Some(Condition::MasculineDevsger),
764        );
765        assert_eq!(token.written, Some(&[WrittenUnit::H][..]));
766
767        // (b) The first FVS-carrying variant that owns a condition and whose written units
768        // differ from that (cp, position)'s default: the condition must pick the variant.
769        let (letter, variant, condition) = mng::DATA
770            .letters
771            .iter()
772            .flat_map(|letter| letter.variants.iter().map(move |variant| (letter, variant)))
773            .filter_map(|(letter, variant)| {
774                let condition = *variant.conditions.first()?;
775                variant.fvs?;
776                let default = shaper.defaults.get(&(letter.cp, variant.position))?;
777                (default.written != variant.written
778                    && shaper.condition_fvs(letter.cp, variant.position, condition)
779                        == Some(variant.fvs))
780                .then_some((letter, variant, condition))
781            })
782            .next()
783            .expect("MNG has an FVS variant selected by a condition");
784        let default = shaper.defaults[&(letter.cp, variant.position)].written;
785        let token = resolved(&shaper, letter.cp, variant.position, Some(condition));
786        assert_eq!(
787            token.written,
788            Some(variant.written),
789            "U+{:04X} {} {}",
790            letter.cp,
791            variant.position,
792            condition.as_str()
793        );
794        assert_ne!(token.written, Some(default));
795
796        // (c) An FVS on the token wins over the condition: h.init + FVS1 is `Hx`, while the
797        // `feminine` condition would select h.init.fvs2 = `G`.
798        let mut token = shaper.tokenize("\u{182C}\u{180B}").remove(0);
799        token.position = Position::Init;
800        token.condition = Some(Condition::Feminine);
801        shaper.resolve_written(&mut token);
802        assert_eq!(token.written, Some(&[WrittenUnit::Hx][..]));
803
804        // (d) A condition unknown to the position falls back to the default: h.fina has no
805        // `feminine` variant, so the default `H` is used.
806        assert_eq!(
807            shaper.condition_fvs(0x182C, Position::Fina, Condition::Feminine),
808            None
809        );
810        let token = resolved(&shaper, 0x182C, Position::Fina, Some(Condition::Feminine));
811        assert_eq!(token.written, Some(&[WrittenUnit::H][..]));
812    }
813
814    #[test]
815    fn memoised_written_is_never_invalidated() {
816        let shaper = Shaper::new(Locale::Mng);
817        // `resolve_written` memoises: once resolved, a token keeps its written units whatever
818        // condition a later rule sets.
819        let mut token = shaper.tokenize("\u{1820}").remove(0);
820        token.position = Position::Fina;
821        shaper.resolve_written(&mut token);
822        assert_eq!(token.written, Some(&[WrittenUnit::A][..]));
823        token.condition = Some(Condition::PostBowed);
824        shaper.resolve_written(&mut token);
825        assert_eq!(token.written, Some(&[WrittenUnit::A][..]));
826
827        // No rule pair runs into this now that the context lookups stay inside the MVS segment:
828        // III.4 resolves the vowel before a medial `i`, which is never final, and III.5 only
829        // touches final vowels. `b a MVS nirugu i n` used to: III.4 walked back from the `i`
830        // across the MVS, froze the `a` at `A`, and III.5's `post_bowed` came too late. It now
831        // shapes as the font does, with `post_bowed` `Aa` and a single `I`.
832        assert_eq!(
833            shaper
834                .shape("\u{182A}\u{1820}\u{180E}\u{180A}\u{1822}\u{1828}")
835                .unwrap(),
836            vec![
837                WrittenUnit::B,
838                WrittenUnit::Aa,
839                WrittenUnit::Mvs,
840                WrittenUnit::Nirugu,
841                WrittenUnit::I,
842                WrittenUnit::A,
843            ]
844        );
845    }
846
847    #[test]
848    fn debug_reports_the_index_sizes() {
849        let shaper = Shaper::new(Locale::Mng);
850        let text = format!("{shaper:?}");
851        assert!(text.starts_with("Shaper { locale: Mng,"), "{text}");
852        assert!(text.contains("letters: 35"), "{text}");
853        assert!(
854            text.contains(&format!("rules: {}", shaper.rule_names().len())),
855            "{text}"
856        );
857        assert!(text.ends_with(".. }"), "{text}");
858    }
859
860    #[test]
861    fn unknown_letters_shape_to_nothing() {
862        // U+181A is inside the block but has no MNG variants (Python parity).
863        let shaper = Shaper::new(Locale::Mng);
864        assert_eq!(shaper.shape("\u{181A}").unwrap(), Vec::<WrittenUnit>::new());
865        let details = shaper.shape_detailed("\u{181A}").unwrap();
866        assert_eq!(details[0].alias, None);
867    }
868}