Skip to main content

mongol_norm/
tables.rs

1//! Hand-written types that the generated data tables in `src/generated/` are expressed in, plus
2//! the small public enums shared between the tables and the API: [`Locale`], [`Position`],
3//! [`UnitPosition`] and [`Fvs`].
4
5use std::fmt;
6use std::str::FromStr;
7
8use crate::generated::enums::{Alias, Condition, WrittenUnit};
9use crate::Error;
10
11/// A script locale: which data tables and shaping rules a [`Shaper`](crate::Shaper) uses.
12///
13/// Only `Mng` (Hudum, Traditional Mongolian) has shaping rules and a normalize table; the other
14/// three load their variant data and shape default/FVS forms only — exactly like the Python
15/// implementation.
16#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
17pub enum Locale {
18    /// Hudum — Traditional Mongolian (`"MNG"`).
19    Mng,
20    /// Todo (`"TOD"`).
21    Tod,
22    /// Sibe (`"SIB"`).
23    Sib,
24    /// Manchu (`"MCH"`).
25    Mch,
26}
27
28impl Locale {
29    /// Every locale.
30    pub const ALL: [Locale; 4] = [Locale::Mng, Locale::Tod, Locale::Sib, Locale::Mch];
31
32    /// The contract name used by the Python API, the CLI and the data files.
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Locale::Mng => "MNG",
36            Locale::Tod => "TOD",
37            Locale::Sib => "SIB",
38            Locale::Mch => "MCH",
39        }
40    }
41}
42
43impl FromStr for Locale {
44    type Err = Error;
45
46    fn from_str(name: &str) -> Result<Locale, Error> {
47        Locale::ALL
48            .iter()
49            .copied()
50            .find(|locale| locale.as_str() == name)
51            .ok_or_else(|| Error::UnknownName {
52                kind: "locale",
53                name: name.to_owned(),
54            })
55    }
56}
57
58impl fmt::Display for Locale {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(self.as_str())
61    }
62}
63
64/// Joining-topology position of a letter within its chain.
65#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
66pub enum Position {
67    /// Isolated (`"isol"`).
68    Isol,
69    /// Initial (`"init"`).
70    Init,
71    /// Medial (`"medi"`).
72    Medi,
73    /// Final (`"fina"`).
74    Fina,
75}
76
77impl Position {
78    /// Every position, in `isol, init, medi, fina` order.
79    pub const ALL: [Position; 4] = [
80        Position::Isol,
81        Position::Init,
82        Position::Medi,
83        Position::Fina,
84    ];
85
86    /// The contract name (`"isol"`, `"init"`, `"medi"`, `"fina"`).
87    pub const fn as_str(self) -> &'static str {
88        match self {
89            Position::Isol => "isol",
90            Position::Init => "init",
91            Position::Medi => "medi",
92            Position::Fina => "fina",
93        }
94    }
95}
96
97impl FromStr for Position {
98    type Err = Error;
99
100    fn from_str(name: &str) -> Result<Position, Error> {
101        Position::ALL
102            .iter()
103            .copied()
104            .find(|position| position.as_str() == name)
105            .ok_or_else(|| Error::UnknownName {
106                kind: "position",
107                name: name.to_owned(),
108            })
109    }
110}
111
112impl fmt::Display for Position {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118/// Position of a written unit in the authoritative HUD inventory, as accepted by
119/// [`Shaper::normalize_positioned_written_units`](crate::Shaper::normalize_positioned_written_units).
120///
121/// This is *not* a Unicode letter's joining topology ([`Position`]): isolated FA borrows
122/// `F:init`, and the structural units `Mvs` / `Nirugu` use `Control`.
123#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
124pub enum UnitPosition {
125    /// `"isol"`.
126    Isol,
127    /// `"init"`.
128    Init,
129    /// `"medi"`.
130    Medi,
131    /// `"fina"`.
132    Fina,
133    /// `"control"` — for `Mvs` and `Nirugu`.
134    Control,
135}
136
137impl UnitPosition {
138    /// Every unit position.
139    pub const ALL: [UnitPosition; 5] = [
140        UnitPosition::Isol,
141        UnitPosition::Init,
142        UnitPosition::Medi,
143        UnitPosition::Fina,
144        UnitPosition::Control,
145    ];
146
147    /// The contract name (`"isol"`, `"init"`, `"medi"`, `"fina"`, `"control"`).
148    pub const fn as_str(self) -> &'static str {
149        match self {
150            UnitPosition::Isol => "isol",
151            UnitPosition::Init => "init",
152            UnitPosition::Medi => "medi",
153            UnitPosition::Fina => "fina",
154            UnitPosition::Control => "control",
155        }
156    }
157
158    /// The letter position this names, or `None` for `Control`.
159    pub const fn as_position(self) -> Option<Position> {
160        match self {
161            UnitPosition::Isol => Some(Position::Isol),
162            UnitPosition::Init => Some(Position::Init),
163            UnitPosition::Medi => Some(Position::Medi),
164            UnitPosition::Fina => Some(Position::Fina),
165            UnitPosition::Control => None,
166        }
167    }
168}
169
170impl From<Position> for UnitPosition {
171    fn from(position: Position) -> UnitPosition {
172        match position {
173            Position::Isol => UnitPosition::Isol,
174            Position::Init => UnitPosition::Init,
175            Position::Medi => UnitPosition::Medi,
176            Position::Fina => UnitPosition::Fina,
177        }
178    }
179}
180
181impl FromStr for UnitPosition {
182    type Err = Error;
183
184    fn from_str(name: &str) -> Result<UnitPosition, Error> {
185        UnitPosition::ALL
186            .iter()
187            .copied()
188            .find(|position| position.as_str() == name)
189            .ok_or_else(|| Error::UnknownName {
190                kind: "unit position",
191                name: name.to_owned(),
192            })
193    }
194}
195
196impl fmt::Display for UnitPosition {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        f.write_str(self.as_str())
199    }
200}
201
202/// A free variation selector (U+180B FVS1, U+180C FVS2, U+180D FVS3, U+180F FVS4).
203#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
204pub enum Fvs {
205    /// U+180B.
206    Fvs1,
207    /// U+180C.
208    Fvs2,
209    /// U+180D.
210    Fvs3,
211    /// U+180F.
212    Fvs4,
213}
214
215impl Fvs {
216    /// Every selector, FVS1 first.
217    pub const ALL: [Fvs; 4] = [Fvs::Fvs1, Fvs::Fvs2, Fvs::Fvs3, Fvs::Fvs4];
218
219    /// The selector's code point.
220    pub const fn cp(self) -> u32 {
221        match self {
222            Fvs::Fvs1 => 0x180B,
223            Fvs::Fvs2 => 0x180C,
224            Fvs::Fvs3 => 0x180D,
225            Fvs::Fvs4 => 0x180F,
226        }
227    }
228
229    /// The selector as a `char`.
230    pub const fn as_char(self) -> char {
231        match self {
232            Fvs::Fvs1 => '\u{180B}',
233            Fvs::Fvs2 => '\u{180C}',
234            Fvs::Fvs3 => '\u{180D}',
235            Fvs::Fvs4 => '\u{180F}',
236        }
237    }
238
239    /// `1` for FVS1 … `4` for FVS4 (the `fvs` integer of the data files).
240    pub const fn index(self) -> u8 {
241        match self {
242            Fvs::Fvs1 => 1,
243            Fvs::Fvs2 => 2,
244            Fvs::Fvs3 => 3,
245            Fvs::Fvs4 => 4,
246        }
247    }
248
249    /// The selector for a code point, if it is one.
250    pub const fn from_cp(cp: u32) -> Option<Fvs> {
251        match cp {
252            0x180B => Some(Fvs::Fvs1),
253            0x180C => Some(Fvs::Fvs2),
254            0x180D => Some(Fvs::Fvs3),
255            0x180F => Some(Fvs::Fvs4),
256            _ => None,
257        }
258    }
259
260    /// The selector for a data-file `fvs` integer (`1..=4`).
261    pub const fn from_index(index: u8) -> Option<Fvs> {
262        match index {
263            1 => Some(Fvs::Fvs1),
264            2 => Some(Fvs::Fvs2),
265            3 => Some(Fvs::Fvs3),
266            4 => Some(Fvs::Fvs4),
267            _ => None,
268        }
269    }
270}
271
272// ── Table types (crate-private; instantiated only by the generated statics) ─────────────────
273
274/// One letter of a locale: its code point, alias and every shaping variant (JSON order).
275pub(crate) struct Letter {
276    pub cp: u32,
277    pub alias: Alias,
278    pub variants: &'static [Variant],
279}
280
281/// One `(position, fvs)` variant of a letter.
282pub(crate) struct Variant {
283    pub position: Position,
284    pub fvs: Option<Fvs>,
285    pub written: &'static [WrittenUnit],
286    pub default: bool,
287    pub conditions: &'static [Condition],
288}
289
290/// Phonological categories of a locale (aliases).
291pub(crate) struct Categories {
292    pub vowel: &'static [Alias],
293    pub consonant: &'static [Alias],
294    pub vowel_masculine: &'static [Alias],
295    pub vowel_feminine: &'static [Alias],
296    pub vowel_neuter: &'static [Alias],
297}
298
299/// One symbol of a particle-dictionary key.
300#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
301pub(crate) enum ParticleSym {
302    /// The MVS marker (`mvs` in the data).
303    Mvs,
304    /// A letter alias.
305    Alias(Alias),
306    /// A letter without an alias in this locale — never appears in generated keys, so any
307    /// segment containing one never matches (Python: an empty alias never matches either).
308    Unknown,
309}
310
311/// One particle-dictionary entry: alias sequence → token indices that take `particle`.
312pub(crate) struct Particle {
313    pub key: &'static [ParticleSym],
314    pub indices: &'static [usize],
315}
316
317/// Everything the shaper needs for one locale.
318pub(crate) struct LocaleData {
319    pub letters: &'static [Letter],
320    pub categories: Categories,
321    pub particles: &'static [Particle],
322}
323
324/// One normalize-table entry: `(position, written units) → (letter code point, FVS)`.
325pub(crate) struct UnitEntry {
326    pub position: Position,
327    pub units: &'static [WrittenUnit],
328    pub cp: u32,
329    pub fvs: Option<Fvs>,
330}
331
332/// The normalize table of a locale (`MNG.normalize.json`).
333pub(crate) struct NormalizeData {
334    pub canonical_version: &'static str,
335    pub unit_enc_max_len: usize,
336    pub unit_table: &'static [UnitEntry],
337    pub velar_fem: &'static [UnitEntry],
338    pub velar_fem_units: &'static [WrittenUnit],
339    /// `(masculine cp, feminine cp)` pairs; only the masculine side is consulted at runtime.
340    pub masc_to_fem: &'static [(u32, u32)],
341    pub positioned_units: &'static [(WrittenUnit, Position)],
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn locale_names_round_trip() {
350        for locale in Locale::ALL {
351            assert_eq!(locale.as_str().parse::<Locale>().unwrap(), locale);
352        }
353        assert!("MNGx".parse::<Locale>().is_err());
354        assert!("mng".parse::<Locale>().is_err());
355    }
356
357    #[test]
358    fn position_names_round_trip() {
359        for position in Position::ALL {
360            assert_eq!(position.as_str().parse::<Position>().unwrap(), position);
361            assert_eq!(UnitPosition::from(position).as_position(), Some(position));
362        }
363        assert_eq!(
364            "control".parse::<UnitPosition>().unwrap(),
365            UnitPosition::Control
366        );
367        assert_eq!(UnitPosition::Control.as_position(), None);
368        assert!("control".parse::<Position>().is_err());
369        assert!("middle".parse::<UnitPosition>().is_err());
370    }
371
372    #[test]
373    fn fvs_code_points() {
374        for fvs in Fvs::ALL {
375            assert_eq!(Fvs::from_cp(fvs.cp()), Some(fvs));
376            assert_eq!(Fvs::from_index(fvs.index()), Some(fvs));
377            assert_eq!(fvs.as_char() as u32, fvs.cp());
378        }
379        assert_eq!(Fvs::from_cp(0x180E), None);
380        assert_eq!(Fvs::from_index(0), None);
381    }
382    #[test]
383    fn generated_enums_round_trip() {
384        for unit in WrittenUnit::ALL {
385            assert_eq!(unit.as_str().parse::<WrittenUnit>().unwrap(), unit);
386            assert_eq!(unit.to_string(), unit.as_str());
387        }
388        for condition in Condition::ALL {
389            assert_eq!(condition.as_str().parse::<Condition>().unwrap(), condition);
390        }
391        for alias in Alias::ALL {
392            assert_eq!(alias.as_str().parse::<Alias>().unwrap(), alias);
393        }
394        assert!(WrittenUnit::Mvs.is_structural());
395        assert!(WrittenUnit::Nirugu.is_structural());
396        assert!(WrittenUnit::Zwj.is_structural());
397        assert!(!WrittenUnit::A.is_structural());
398        assert_eq!(Condition::ChachlagOnsetGb.as_str(), "chachlag_onset_gb");
399        assert_eq!(Alias::K2.as_str(), "k2");
400        assert_eq!(Alias::Oe.as_str(), "oe");
401        assert!("mvs".parse::<WrittenUnit>().is_err());
402        assert!("MVS".parse::<WrittenUnit>().is_err());
403    }
404
405    #[test]
406    fn generated_tables_have_the_expected_shape() {
407        use crate::generated::{mng, mng_normalize};
408        assert_eq!(mng::DATA.letters.len(), 35);
409        assert_eq!(mng::DATA.particles.len(), 47);
410        assert_eq!(
411            mng::DATA.categories.vowel_masculine,
412            &[Alias::A, Alias::O, Alias::U]
413        );
414        let variants: usize = mng::DATA.letters.iter().map(|l| l.variants.len()).sum();
415        assert_eq!(variants, 216);
416        assert_eq!(mng_normalize::DATA.canonical_version, "mng-canonical/1");
417        assert_eq!(mng_normalize::DATA.unit_enc_max_len, 3);
418        assert_eq!(mng_normalize::DATA.unit_table.len(), 151);
419        assert_eq!(mng_normalize::DATA.velar_fem.len(), 15);
420        assert_eq!(mng_normalize::DATA.positioned_units.len(), 95);
421        assert_eq!(
422            mng_normalize::DATA.masc_to_fem,
423            &[(0x1820, 0x1821), (0x1823, 0x1825), (0x1824, 0x1826)]
424        );
425        // Every (cp, position) has exactly one default, in every locale.
426        for data in [
427            &crate::generated::mng::DATA,
428            &crate::generated::tod::DATA,
429            &crate::generated::sib::DATA,
430            &crate::generated::mch::DATA,
431        ] {
432            for letter in data.letters {
433                for position in Position::ALL {
434                    let defaults = letter
435                        .variants
436                        .iter()
437                        .filter(|v| v.position == position && v.default)
438                        .count();
439                    let any = letter.variants.iter().any(|v| v.position == position);
440                    assert_eq!(
441                        defaults,
442                        usize::from(any),
443                        "U+{:04X} {:?}",
444                        letter.cp,
445                        position
446                    );
447                }
448            }
449        }
450    }
451}