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