Skip to main content

mongol_norm/
written_units.rs

1//! Written-unit input APIs — a port of `normalize_written_units`,
2//! `normalize_positioned_written_units` and `_parse_written_units` from `mongol_norm/shaper.py`.
3
4use crate::generated::enums::WrittenUnit;
5use crate::normalize::{is_joiner, slot_position, NormalizeTable};
6use crate::shaper::Shaper;
7use crate::tables::{Position, UnitPosition};
8use crate::Error;
9
10/// One record of [`Shaper::normalize_positioned_written_units`]: a written unit with its
11/// authoritative HUD inventory position (`Control` for `Mvs` / `Nirugu`).
12///
13/// This is an *input* type: both fields are public and it is deliberately not
14/// `#[non_exhaustive]` (unlike the crate's result types), so callers can build records with a
15/// struct literal or [`PositionedWrittenUnit::new`] and destructure them freely.
16#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
17pub struct PositionedWrittenUnit {
18    /// The written unit.
19    pub unit: WrittenUnit,
20    /// Its HUD inventory position.
21    pub position: UnitPosition,
22}
23
24impl PositionedWrittenUnit {
25    /// Build a record.
26    pub const fn new(unit: WrittenUnit, position: UnitPosition) -> PositionedWrittenUnit {
27        PositionedWrittenUnit { unit, position }
28    }
29}
30
31/// The positioned API accepts at most this many records (Python parity).
32pub const MAX_POSITIONED_RECORDS: usize = 1024;
33
34enum PositionedPart {
35    Control(WrittenUnit),
36    Chain(Vec<(WrittenUnit, Position)>),
37}
38
39/// Does `part` cursively join the chain next to it? Only a joiner control does; a neighbouring
40/// chain never does (Python tests `parts[i][0] in _JOINER_TOKENS`, and a chain part's tag is the
41/// literal `"chain"`).
42fn is_joiner_part(part: &PositionedPart) -> bool {
43    matches!(part, PositionedPart::Control(unit) if is_joiner(*unit))
44}
45
46impl Shaper {
47    /// Encode an ordered written-unit sequence (e.g. the output of [`Shaper::shape`]) as
48    /// canonical Unicode. Letter positions are inferred from order and the structural tokens;
49    /// ZWJ is emitted only where `Zwj` is present in the request *and* changes the ink — the
50    /// request goes through the same redundant-ZWJ filter and duplicate unification as
51    /// [`Shaper::shape`], so `[L, I, Zwj, Aa]` is encoded as `L G`, exactly like the text
52    /// `ᠯᠢ\u{200D}ᠡ᠋`. The result is accepted only if it reshapes to that folded sequence.
53    ///
54    /// An empty sequence returns `""`.
55    ///
56    /// # Errors
57    ///
58    /// - [`Error::UnsupportedWrittenUnit`] — `units[index]` is outside the table's vocabulary.
59    ///   The first offender is reported. Every [`WrittenUnit`] is in the bundled table, so this
60    ///   is a guard rather than an expected outcome.
61    /// - [`Error::NoCanonicalEncoding`] — the table covers every unit, but the sequence has no
62    ///   encoding that reshapes back to it.
63    pub fn normalize_written_units(&self, units: &[WrittenUnit]) -> Result<String, Error> {
64        if units.is_empty() {
65            return Ok(String::new());
66        }
67        let table = self.table();
68        for (index, unit) in units.iter().enumerate() {
69            if !table.known_units.contains(unit) {
70                return Err(Error::UnsupportedWrittenUnit { index, unit: *unit });
71            }
72        }
73        // Duplicate encodings and redundant ZWJs are accepted on input — `Dd`, medial `H`/`Hx`
74        // and an interior `Zwj` are real units in the standard and in callers' data — and folded
75        // before encoding, exactly as `shape` folds them, so they get the same canonical text as
76        // the sequence they render identically to.
77        let units = self.canonical_units(units);
78        let canonical = self.canonical_for_shape(&units)?;
79        if canonical.is_empty() || self.shape(&canonical)? != units {
80            return Err(Error::NoCanonicalEncoding);
81        }
82        Ok(canonical)
83    }
84
85    /// Encode explicit HUD-position records as canonical Unicode (the API `zvvnmod-utn57`
86    /// uses). A complete multi-record chain runs `init…fina`; an incomplete edge gets an implicit
87    /// ZWJ; a single `init` record is encoded bare where that bare letter reads back as the same
88    /// record — every consonant, whose isolated form is its initial unit — and takes a trailing
89    /// ZWJ where it would not: `A` and `I`, which the inventory also carries at `isol`, and `O`,
90    /// which has no bare spelling; single `medi` / `fina` records get the joining context their
91    /// position needs. `Mvs` and
92    /// `Nirugu` require `Control`; explicit `Zwj` is rejected; at most
93    /// [`MAX_POSITIONED_RECORDS`] records.
94    ///
95    /// # Validation order
96    ///
97    /// Checks run in a fixed order, and it is observable — callers such as `zvvnmod-utn57`
98    /// dispatch on the variant, so the *first* failing check decides which one they see:
99    ///
100    /// 1. record limit — 2000 records containing a `Zwj` is [`Error::TooManyRecords`], not
101    ///    [`Error::ExplicitZwj`];
102    /// 2. explicit `Zwj` anywhere in the request;
103    /// 3. empty request — returns `""`;
104    /// 4. per record, the control/inventory check (`Mvs` and `Nirugu` need `Control`; every other
105    ///    `(unit, position)` must be in the HUD inventory), reporting the first offender;
106    /// 5. per multi-record chain, that the declared positions match the padded chain;
107    /// 6. delegation to [`Shaper::normalize_written_units`].
108    ///
109    /// # Errors
110    ///
111    /// - [`Error::TooManyRecords`], [`Error::ExplicitZwj`],
112    ///   [`Error::ControlRequiresControlPosition`], [`Error::UnsupportedPositionedUnit`],
113    ///   [`Error::ChainPositionMismatch`] — as numbered above.
114    /// - Anything [`Shaper::normalize_written_units`] returns. **Its indices point into the
115    ///   expanded written-unit sequence — after implicit ZWJs were inserted — not into
116    ///   `records`.**
117    pub fn normalize_positioned_written_units(
118        &self,
119        records: &[PositionedWrittenUnit],
120    ) -> Result<String, Error> {
121        if records.len() > MAX_POSITIONED_RECORDS {
122            return Err(Error::TooManyRecords {
123                max: MAX_POSITIONED_RECORDS,
124            });
125        }
126        if records.iter().any(|record| record.unit == WrittenUnit::Zwj) {
127            return Err(Error::ExplicitZwj);
128        }
129        if records.is_empty() {
130            return Ok(String::new());
131        }
132        let table = self.table();
133        for (index, record) in records.iter().enumerate() {
134            if matches!(record.unit, WrittenUnit::Mvs | WrittenUnit::Nirugu) {
135                if record.position != UnitPosition::Control {
136                    return Err(Error::ControlRequiresControlPosition {
137                        index,
138                        unit: record.unit,
139                    });
140                }
141                continue;
142            }
143            let supported = record
144                .position
145                .as_position()
146                .is_some_and(|position| table.positioned_units.contains(&(record.unit, position)));
147            if !supported {
148                return Err(Error::UnsupportedPositionedUnit {
149                    index,
150                    unit: record.unit,
151                    position: record.position,
152                });
153            }
154        }
155
156        let mut parts: Vec<PositionedPart> = Vec::new();
157        let mut chain: Vec<(WrittenUnit, Position)> = Vec::new();
158        for record in records {
159            match record.position.as_position() {
160                None => {
161                    if !chain.is_empty() {
162                        parts.push(PositionedPart::Chain(std::mem::take(&mut chain)));
163                    }
164                    parts.push(PositionedPart::Control(record.unit));
165                }
166                Some(position) => chain.push((record.unit, position)),
167            }
168        }
169        if !chain.is_empty() {
170            parts.push(PositionedPart::Chain(chain));
171        }
172        let mut written: Vec<WrittenUnit> = Vec::new();
173        for index in 0..parts.len() {
174            let body = match &parts[index] {
175                PositionedPart::Control(unit) => {
176                    written.push(*unit);
177                    continue;
178                }
179                PositionedPart::Chain(body) => body,
180            };
181            let joined_left = index > 0 && is_joiner_part(&parts[index - 1]);
182            let joined_right = index + 1 < parts.len() && is_joiner_part(&parts[index + 1]);
183            if body.len() == 1 {
184                let (unit, position) = body[0];
185                // `records.len()`, not `body.len()`: the trailing ZWJ is for a request that is
186                // NOTHING but the `init` record. `[Nirugu:control, O:init]` also has a one-unit
187                // body, but the nirugu already supplies the joining context — it must not get the
188                // ZWJ.
189                if records.len() == 1
190                    && position == Position::Init
191                    && self.lone_init_needs_zwj(table, unit)
192                {
193                    written.extend([unit, WrittenUnit::Zwj]);
194                    continue;
195                }
196                if matches!(position, Position::Medi | Position::Fina) && !joined_left {
197                    written.push(WrittenUnit::Zwj);
198                }
199                written.push(unit);
200                if position == Position::Medi && !joined_right {
201                    written.push(WrittenUnit::Zwj);
202                }
203                continue;
204            }
205            // One flag per side folds together the two ways a side can be non-initial/non-final:
206            // a joiner control already sits there, or the chain simply does not start at `init` /
207            // end at `fina` (so a ZWJ has to be invented). Either way the chain's positions are
208            // computed as if one extra unit padded that side, which is exactly `padded_count`.
209            let padded_left = joined_left || body[0].1 != Position::Init;
210            let padded_right = joined_right || body[body.len() - 1].1 != Position::Fina;
211            let padded_count = body.len() + usize::from(padded_left) + usize::from(padded_right);
212            // `length = 1` because each record is one written unit occupying one slot; multi-unit
213            // letters are the *encoder's* concern (`unit_partition`), not the caller's request.
214            for (offset, (_unit, position)) in body.iter().enumerate() {
215                let expected = slot_position(offset + usize::from(padded_left), 1, padded_count);
216                if *position != expected {
217                    return Err(Error::ChainPositionMismatch);
218                }
219            }
220            if padded_left && !joined_left {
221                written.push(WrittenUnit::Zwj);
222            }
223            written.extend(body.iter().map(|(unit, _)| *unit));
224            if padded_right && !joined_right {
225                written.push(WrittenUnit::Zwj);
226            }
227        }
228        self.normalize_written_units(&written)
229    }
230
231    /// Does a request that is nothing but `unit` at `init` need a trailing ZWJ to read back as
232    /// `init`?
233    ///
234    /// Bare, a lone letter is an isolated chain. That is still the `init` record wherever the
235    /// isolated form *is* the initial written unit — an isolated consonant borrows it (`B`, `Ch`,
236    /// `D`, …) — so those stay bare. Two kinds of letter would read back as something else and
237    /// take the joiner (Satsrag/meco-rust#45): one the inventory also carries at `isol` under the
238    /// same bare spelling (`A`, `I`), and one with no bare spelling of its own (`O`, whose bare
239    /// letter is `U:isol`).
240    fn lone_init_needs_zwj(&self, table: &NormalizeTable, unit: WrittenUnit) -> bool {
241        table.positioned_units.contains(&(unit, Position::Isol))
242            || self.normalize_written_units(&[unit]).is_err()
243    }
244
245    /// Parse the CLI's written-unit spelling: explicit `+`-separated names (`B+Aa`) or a
246    /// compact PascalCase string with exactly one segmentation over this shaper's known units
247    /// (`BZwj`). One trailing newline is tolerated; an ambiguous compact string is rejected.
248    ///
249    /// Every name — explicit or compact — must belong to *this shaper's normalize table*, not
250    /// merely to [`WrittenUnit`]. That keeps the reported index identical to Python's, which
251    /// defers the same check to `normalize_written_units`.
252    ///
253    /// # Errors
254    ///
255    /// - [`Error::InvalidUnitSpec`] — the spec contains whitespace or an empty `+` field, or the
256    ///   compact string has more than one segmentation.
257    /// - [`Error::UnknownWrittenUnit`] — a name is outside the table's vocabulary; on the compact
258    ///   path an unsegmentable string is reported whole, at index 0.
259    pub fn parse_written_units(&self, text: &str) -> Result<Vec<WrittenUnit>, Error> {
260        let table = self.table();
261        parse_unit_names(text, &table.sorted_vocabulary)?
262            .into_iter()
263            .enumerate()
264            .map(|(index, name)| {
265                // Membership is tested against the TABLE's vocabulary, not merely "is this a
266                // `WrittenUnit` variant": Python leaves every name unresolved here and lets
267                // `normalize_written_units` reject the first one missing from `known_units`, so a
268                // unit the enum knows but the table does not must fail at Python's index too.
269                // — see shaper.py::normalize_written_units
270                name.parse::<WrittenUnit>()
271                    .ok()
272                    .filter(|unit| table.known_units.contains(unit))
273                    .ok_or_else(|| Error::UnknownWrittenUnit {
274                        index,
275                        unit: name.to_owned(),
276                    })
277            })
278            .collect()
279    }
280}
281
282impl Shaper {
283    /// Every written unit this shaper's normalize table can encode, plus the structural
284    /// controls (`Mvs`, `Nirugu`, `Zwj`), sorted by name.
285    ///
286    /// These are exactly the names [`Shaper::normalize_written_units`] accepts; the Python
287    /// bindings use the list to validate their input with Python-formatted messages.
288    ///
289    /// # Errors
290    ///
291    /// None: the `Result` remains from the locales without a normalize table the crate used to
292    /// have.
293    pub fn known_written_units(&self) -> Result<Vec<WrittenUnit>, Error> {
294        let table = self.table();
295        let mut units: Vec<WrittenUnit> = table.known_units.iter().copied().collect();
296        units.sort_by_key(|unit| unit.as_str());
297        Ok(units)
298    }
299
300    /// The HUD positioned inventory: every `(unit, position)` pair that
301    /// [`Shaper::normalize_positioned_written_units`] accepts for a letter record (letter
302    /// positions only — controls are not part of the inventory), sorted by unit name then
303    /// position.
304    ///
305    /// # Errors
306    ///
307    /// None: the `Result` remains from the locales without a normalize table the crate used to
308    /// have.
309    pub fn positioned_written_units(&self) -> Result<Vec<(WrittenUnit, Position)>, Error> {
310        let table = self.table();
311        let mut records: Vec<(WrittenUnit, Position)> =
312            table.positioned_units.iter().copied().collect();
313        records.sort_by_key(|(unit, position)| (unit.as_str(), position.as_str()));
314        Ok(records)
315    }
316}
317
318fn strip_one_newline(text: &str) -> &str {
319    text.strip_suffix("\r\n")
320        .or_else(|| text.strip_suffix('\n'))
321        .or_else(|| text.strip_suffix('\r'))
322        .unwrap_or(text)
323}
324
325/// Python `_parse_written_units`: explicit `+` names, or a unique compact segmentation over
326/// `vocabulary`. Returns borrowed names — slices of `text` on the `+` path, vocabulary entries on
327/// the compact path — which the caller maps to units.
328///
329/// `vocabulary` is expected in Python's `(-len, name)` order (see
330/// [`crate::normalize::NormalizeTable::sorted_vocabulary`]), but that order is *not* semantically load-bearing: the
331/// per-offset count saturates at 2 and `choice` is cleared by any second match, so the final
332/// `(count, choice)` pair is independent of iteration order. Sorting only lets the longest
333/// candidates hit the `count == 2` early `break` sooner.
334pub(crate) fn parse_unit_names<'v>(
335    text: &'v str,
336    vocabulary: &[&'v str],
337) -> Result<Vec<&'v str>, Error> {
338    let text = strip_one_newline(text);
339    if text.is_empty() {
340        return Ok(Vec::new());
341    }
342    if text.chars().any(char::is_whitespace) {
343        return Err(Error::InvalidUnitSpec(
344            "written units cannot be empty or contain whitespace".to_owned(),
345        ));
346    }
347    if text.contains('+') {
348        let units: Vec<&str> = text.split('+').collect();
349        if units
350            .iter()
351            .any(|unit| unit.is_empty() || *unit != unit.trim())
352        {
353            return Err(Error::InvalidUnitSpec(
354                "written units cannot be empty or contain whitespace; separate explicit units \
355                 with '+' (for example A+Aa+B+Zwj)"
356                    .to_owned(),
357            ));
358        }
359        return Ok(units);
360    }
361    // Right-to-left DP over byte offsets. Invariant: `parse_counts[i]` is the number of ways
362    // `text[i..]` segments into vocabulary units, saturated at 2 — 0 = impossible, 1 = exactly
363    // one segmentation, 2 = "two or more", i.e. ambiguous. The empty suffix has one (the empty)
364    // segmentation, hence `parse_counts[n] = 1`. `choices[i]` is the unit that starts `text[i..]`
365    // in its unique parse, plus where that unit ends.
366    let n = text.len();
367    let mut parse_counts = vec![0u8; n + 1];
368    let mut choices: Vec<Option<(&'v str, usize)>> = vec![None; n + 1];
369    parse_counts[n] = 1;
370    for offset in (0..n).rev() {
371        if !text.is_char_boundary(offset) {
372            continue; // inside a multi-byte char: no unit can start here (count stays 0)
373        }
374        let mut count = 0u8;
375        let mut choice = None;
376        for unit in vocabulary.iter() {
377            let end = offset + unit.len();
378            if end > n || !text[offset..].starts_with(unit) || parse_counts[end] == 0 {
379                continue;
380            }
381            if count == 0 && parse_counts[end] == 1 {
382                choice = Some((*unit, end));
383            } else {
384                choice = None;
385            }
386            count = (count + parse_counts[end]).min(2);
387            if count == 2 {
388                break;
389            }
390        }
391        parse_counts[offset] = count;
392        if count == 1 {
393            // `count` lands on exactly 1 only when a single match contributed, and that match
394            // took the `count == 0 && parse_counts[end] == 1` branch — any second match would
395            // both clear `choice` and push `count` to 2. So `choice` is `Some` here, which is
396            // what the `expect` below relies on.
397            choices[offset] = choice;
398        }
399    }
400    match parse_counts[0] {
401        0 => Err(Error::UnknownWrittenUnit {
402            index: 0,
403            unit: text.to_owned(),
404        }),
405        1 => {
406            let mut units = Vec::new();
407            let mut offset = 0;
408            while offset < n {
409                let (unit, end) = choices[offset].expect("a unique parse records its choice");
410                units.push(unit);
411                offset = end;
412            }
413            Ok(units)
414        }
415        _ => Err(Error::InvalidUnitSpec(
416            "compact written-unit sequence is ambiguous; separate units with '+'".to_owned(),
417        )),
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::Locale;
425
426    #[test]
427    fn a_unit_outside_the_table_is_rejected_with_its_index() {
428        // Every `WrittenUnit` is in the bundled table, so the guard is exercised on the empty
429        // test table (which knows only the structural units).
430        let shaper = Shaper::with_empty_normalize_table(Locale::Mng);
431        let error = shaper
432            .normalize_written_units(&[WrittenUnit::Mvs, WrittenUnit::B])
433            .unwrap_err();
434        assert_eq!(
435            error,
436            Error::UnsupportedWrittenUnit {
437                index: 1,
438                unit: WrittenUnit::B
439            }
440        );
441        assert_eq!(error.to_string(), "written_units[1] is unknown: 'B'");
442    }
443
444    #[test]
445    fn ambiguous_compact_units_require_plus_separators() {
446        // Python: `_parse_written_units("AAA", {"A", "AA"})` → "ambiguous … +"
447        let error = parse_unit_names("AAA", &["AA", "A"]).unwrap_err();
448        assert!(
449            matches!(&error, Error::InvalidUnitSpec(m) if m.contains("ambiguous") && m.contains('+'))
450        );
451        assert_eq!(parse_unit_names("AA", &["AA", "A"]).unwrap_err(), error);
452        assert_eq!(
453            parse_unit_names("AAB", &["AA", "A", "B"]).unwrap_err(),
454            error
455        );
456        assert_eq!(parse_unit_names("AAB", &["AA", "B"]).unwrap(), ["AA", "B"]);
457    }
458
459    /// The `(-len, name)` order is an optimisation, not semantics: any permutation of the same
460    /// vocabulary yields the same result.
461    #[test]
462    fn vocabulary_order_does_not_change_the_parse() {
463        // {AA, B} parses "AAB" uniquely; {A, AA} makes "AAA" ambiguous. Both hold in any order.
464        for vocabulary in [["AA", "B"], ["B", "AA"]] {
465            assert_eq!(parse_unit_names("AAB", &vocabulary).unwrap(), ["AA", "B"]);
466        }
467        for vocabulary in [["AA", "A"], ["A", "AA"]] {
468            assert!(parse_unit_names("AAA", &vocabulary).is_err());
469            assert_eq!(parse_unit_names("A", &vocabulary).unwrap(), ["A"]);
470        }
471    }
472
473    #[test]
474    fn multibyte_input_never_splits_a_char() {
475        assert_eq!(
476            parse_unit_names("A\u{1820}", &["A"]).unwrap_err(),
477            Error::UnknownWrittenUnit {
478                index: 0,
479                unit: "A\u{1820}".to_owned()
480            }
481        );
482    }
483
484    #[test]
485    fn trailing_transport_newline_is_stripped_once() {
486        assert_eq!(parse_unit_names("A\r\n", &["A"]).unwrap(), ["A"]);
487        assert_eq!(parse_unit_names("A\n", &["A"]).unwrap(), ["A"]);
488        assert_eq!(parse_unit_names("\n", &["A"]).unwrap(), Vec::<&str>::new());
489        assert!(parse_unit_names("A\n\n", &["A"]).is_err());
490    }
491}