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