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