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