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};
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. The result is accepted only if
50    /// it reshapes to exactly the requested sequence.
51    ///
52    /// An empty sequence returns `""` — without consulting the table, so it succeeds on every
53    /// locale.
54    ///
55    /// # Errors
56    ///
57    /// - [`Error::NormalizeUnsupported`] — this locale has no bundled normalize table.
58    /// - [`Error::UnsupportedWrittenUnit`] — `units[index]` is outside the table's vocabulary
59    ///   (for example the Todo unit `E` on an MNG shaper). The first offender is reported.
60    /// - [`Error::NoCanonicalEncoding`] — the table covers every unit, but the sequence has no
61    ///   encoding that reshapes back to it.
62    pub fn normalize_written_units(&self, units: &[WrittenUnit]) -> Result<String, Error> {
63        if units.is_empty() {
64            return Ok(String::new());
65        }
66        let table = self.table()?;
67        for (index, unit) in units.iter().enumerate() {
68            if !table.known_units.contains(unit) {
69                return Err(Error::UnsupportedWrittenUnit { index, unit: *unit });
70            }
71        }
72        let canonical = self.canonical_for_shape(units)?;
73        if canonical.is_empty() || self.shape(&canonical)? != units {
74            return Err(Error::NoCanonicalEncoding);
75        }
76        Ok(canonical)
77    }
78
79    /// Encode explicit HUD-position records as canonical Unicode (the API `zvvnmod-utn57`
80    /// uses). A complete multi-record chain runs `init…fina`; an incomplete edge gets an implicit
81    /// ZWJ; a single `init` record is encoded bare except `O:init`, which takes a trailing ZWJ;
82    /// single `medi` / `fina` records get the joining context their position needs. `Mvs` and
83    /// `Nirugu` require `Control`; explicit `Zwj` is rejected; at most
84    /// [`MAX_POSITIONED_RECORDS`] records.
85    ///
86    /// # Validation order
87    ///
88    /// Checks run in a fixed order, and it is observable — callers such as `zvvnmod-utn57`
89    /// dispatch on the variant, so the *first* failing check decides which one they see:
90    ///
91    /// 1. record limit — 2000 records containing a `Zwj` is [`Error::TooManyRecords`], not
92    ///    [`Error::ExplicitZwj`], and an over-limit request on a table-less locale is
93    ///    [`Error::TooManyRecords`], not [`Error::NormalizeUnsupported`];
94    /// 2. explicit `Zwj` anywhere in the request;
95    /// 3. empty request — returns `""` before the table is consulted, so it succeeds on every
96    ///    locale;
97    /// 4. the normalize table;
98    /// 5. per record, the control/inventory check (`Mvs` and `Nirugu` need `Control`; every other
99    ///    `(unit, position)` must be in the HUD inventory), reporting the first offender;
100    /// 6. per multi-record chain, that the declared positions match the padded chain;
101    /// 7. delegation to [`Shaper::normalize_written_units`].
102    ///
103    /// # Errors
104    ///
105    /// - [`Error::TooManyRecords`], [`Error::ExplicitZwj`], [`Error::NormalizeUnsupported`],
106    ///   [`Error::ControlRequiresControlPosition`], [`Error::UnsupportedPositionedUnit`],
107    ///   [`Error::ChainPositionMismatch`] — as numbered above.
108    /// - Anything [`Shaper::normalize_written_units`] returns. **Its indices point into the
109    ///   expanded written-unit sequence — after implicit ZWJs were inserted — not into
110    ///   `records`.**
111    pub fn normalize_positioned_written_units(
112        &self,
113        records: &[PositionedWrittenUnit],
114    ) -> Result<String, Error> {
115        if records.len() > MAX_POSITIONED_RECORDS {
116            return Err(Error::TooManyRecords {
117                max: MAX_POSITIONED_RECORDS,
118            });
119        }
120        if records.iter().any(|record| record.unit == WrittenUnit::Zwj) {
121            return Err(Error::ExplicitZwj);
122        }
123        if records.is_empty() {
124            return Ok(String::new());
125        }
126        let table = self.table()?;
127        for (index, record) in records.iter().enumerate() {
128            if matches!(record.unit, WrittenUnit::Mvs | WrittenUnit::Nirugu) {
129                if record.position != UnitPosition::Control {
130                    return Err(Error::ControlRequiresControlPosition {
131                        index,
132                        unit: record.unit,
133                    });
134                }
135                continue;
136            }
137            let supported = record
138                .position
139                .as_position()
140                .is_some_and(|position| table.positioned_units.contains(&(record.unit, position)));
141            if !supported {
142                return Err(Error::UnsupportedPositionedUnit {
143                    index,
144                    unit: record.unit,
145                    position: record.position,
146                });
147            }
148        }
149
150        let mut parts: Vec<PositionedPart> = Vec::new();
151        let mut chain: Vec<(WrittenUnit, Position)> = Vec::new();
152        for record in records {
153            match record.position.as_position() {
154                None => {
155                    if !chain.is_empty() {
156                        parts.push(PositionedPart::Chain(std::mem::take(&mut chain)));
157                    }
158                    parts.push(PositionedPart::Control(record.unit));
159                }
160                Some(position) => chain.push((record.unit, position)),
161            }
162        }
163        if !chain.is_empty() {
164            parts.push(PositionedPart::Chain(chain));
165        }
166        let mut written: Vec<WrittenUnit> = Vec::new();
167        for index in 0..parts.len() {
168            let body = match &parts[index] {
169                PositionedPart::Control(unit) => {
170                    written.push(*unit);
171                    continue;
172                }
173                PositionedPart::Chain(body) => body,
174            };
175            let joined_left = index > 0 && is_joiner_part(&parts[index - 1]);
176            let joined_right = index + 1 < parts.len() && is_joiner_part(&parts[index + 1]);
177            if body.len() == 1 {
178                let (unit, position) = body[0];
179                // `records.len()`, not `body.len()`: the trailing ZWJ is for a request that is
180                // NOTHING but `O:init`. `[Nirugu:control, O:init]` also has a one-unit body, but
181                // the nirugu already supplies the joining context — it must not get the ZWJ.
182                if records.len() == 1 && unit == WrittenUnit::O && position == Position::Init {
183                    written.extend([unit, WrittenUnit::Zwj]);
184                    continue;
185                }
186                if matches!(position, Position::Medi | Position::Fina) && !joined_left {
187                    written.push(WrittenUnit::Zwj);
188                }
189                written.push(unit);
190                if position == Position::Medi && !joined_right {
191                    written.push(WrittenUnit::Zwj);
192                }
193                continue;
194            }
195            // One flag per side folds together the two ways a side can be non-initial/non-final:
196            // a joiner control already sits there, or the chain simply does not start at `init` /
197            // end at `fina` (so a ZWJ has to be invented). Either way the chain's positions are
198            // computed as if one extra unit padded that side, which is exactly `padded_count`.
199            let padded_left = joined_left || body[0].1 != Position::Init;
200            let padded_right = joined_right || body[body.len() - 1].1 != Position::Fina;
201            let padded_count = body.len() + usize::from(padded_left) + usize::from(padded_right);
202            // `length = 1` because each record is one written unit occupying one slot; multi-unit
203            // letters are the *encoder's* concern (`unit_partition`), not the caller's request.
204            for (offset, (_unit, position)) in body.iter().enumerate() {
205                let expected = slot_position(offset + usize::from(padded_left), 1, padded_count);
206                if *position != expected {
207                    return Err(Error::ChainPositionMismatch);
208                }
209            }
210            if padded_left && !joined_left {
211                written.push(WrittenUnit::Zwj);
212            }
213            written.extend(body.iter().map(|(unit, _)| *unit));
214            if padded_right && !joined_right {
215                written.push(WrittenUnit::Zwj);
216            }
217        }
218        self.normalize_written_units(&written)
219    }
220
221    /// Parse the CLI's written-unit spelling: explicit `+`-separated names (`B+Aa`) or a
222    /// compact PascalCase string with exactly one segmentation over this shaper's known units
223    /// (`BZwj`). One trailing newline is tolerated; an ambiguous compact string is rejected.
224    ///
225    /// Every name — explicit or compact — must belong to *this shaper's normalize table*, not
226    /// merely to [`WrittenUnit`]: `E` is a Todo unit, so an MNG shaper rejects it. That keeps the
227    /// reported index identical to Python's, which defers the same check to
228    /// `normalize_written_units`.
229    ///
230    /// # Errors
231    ///
232    /// - [`Error::NormalizeUnsupported`] — this locale has no bundled normalize table.
233    /// - [`Error::InvalidUnitSpec`] — the spec contains whitespace or an empty `+` field, or the
234    ///   compact string has more than one segmentation.
235    /// - [`Error::UnknownWrittenUnit`] — a name is outside the table's vocabulary; on the compact
236    ///   path an unsegmentable string is reported whole, at index 0.
237    pub fn parse_written_units(&self, text: &str) -> Result<Vec<WrittenUnit>, Error> {
238        let table = self.table()?;
239        parse_unit_names(text, &table.sorted_vocabulary)?
240            .into_iter()
241            .enumerate()
242            .map(|(index, name)| {
243                // Membership is tested against the TABLE's vocabulary, not merely "is this a
244                // `WrittenUnit` variant": Python leaves every name unresolved here and lets
245                // `normalize_written_units` reject the first one missing from `known_units`, so a
246                // unit the enum knows but this locale's table does not (e.g. the Todo unit `E`)
247                // must fail at Python's index too. — see shaper.py::normalize_written_units
248                name.parse::<WrittenUnit>()
249                    .ok()
250                    .filter(|unit| table.known_units.contains(unit))
251                    .ok_or_else(|| Error::UnknownWrittenUnit {
252                        index,
253                        unit: name.to_owned(),
254                    })
255            })
256            .collect()
257    }
258}
259
260impl Shaper {
261    /// Every written unit this shaper's normalize table can encode, plus the structural
262    /// controls (`Mvs`, `Nirugu`, `Zwj`), sorted by name.
263    ///
264    /// These are exactly the names [`Shaper::normalize_written_units`] accepts; the Python
265    /// bindings use the list to validate their input with Python-formatted messages.
266    ///
267    /// # Errors
268    ///
269    /// [`Error::NormalizeUnsupported`] — this locale has no bundled normalize table.
270    pub fn known_written_units(&self) -> Result<Vec<WrittenUnit>, Error> {
271        let table = self.table()?;
272        let mut units: Vec<WrittenUnit> = table.known_units.iter().copied().collect();
273        units.sort_by_key(|unit| unit.as_str());
274        Ok(units)
275    }
276
277    /// The HUD positioned inventory: every `(unit, position)` pair that
278    /// [`Shaper::normalize_positioned_written_units`] accepts for a letter record (letter
279    /// positions only — controls are not part of the inventory), sorted by unit name then
280    /// position.
281    ///
282    /// # Errors
283    ///
284    /// [`Error::NormalizeUnsupported`] — this locale has no bundled normalize table.
285    pub fn positioned_written_units(&self) -> Result<Vec<(WrittenUnit, Position)>, Error> {
286        let table = self.table()?;
287        let mut records: Vec<(WrittenUnit, Position)> =
288            table.positioned_units.iter().copied().collect();
289        records.sort_by_key(|(unit, position)| (unit.as_str(), position.as_str()));
290        Ok(records)
291    }
292}
293
294fn strip_one_newline(text: &str) -> &str {
295    text.strip_suffix("\r\n")
296        .or_else(|| text.strip_suffix('\n'))
297        .or_else(|| text.strip_suffix('\r'))
298        .unwrap_or(text)
299}
300
301/// Python `_parse_written_units`: explicit `+` names, or a unique compact segmentation over
302/// `vocabulary`. Returns borrowed names — slices of `text` on the `+` path, vocabulary entries on
303/// the compact path — which the caller maps to units.
304///
305/// `vocabulary` is expected in Python's `(-len, name)` order (see
306/// [`crate::normalize::NormalizeTable::sorted_vocabulary`]), but that order is *not* semantically load-bearing: the
307/// per-offset count saturates at 2 and `choice` is cleared by any second match, so the final
308/// `(count, choice)` pair is independent of iteration order. Sorting only lets the longest
309/// candidates hit the `count == 2` early `break` sooner.
310pub(crate) fn parse_unit_names<'v>(
311    text: &'v str,
312    vocabulary: &[&'v str],
313) -> Result<Vec<&'v str>, Error> {
314    let text = strip_one_newline(text);
315    if text.is_empty() {
316        return Ok(Vec::new());
317    }
318    if text.chars().any(char::is_whitespace) {
319        return Err(Error::InvalidUnitSpec(
320            "written units cannot be empty or contain whitespace".to_owned(),
321        ));
322    }
323    if text.contains('+') {
324        let units: Vec<&str> = text.split('+').collect();
325        if units
326            .iter()
327            .any(|unit| unit.is_empty() || *unit != unit.trim())
328        {
329            return Err(Error::InvalidUnitSpec(
330                "written units cannot be empty or contain whitespace; separate explicit units \
331                 with '+' (for example A+Aa+B+Zwj)"
332                    .to_owned(),
333            ));
334        }
335        return Ok(units);
336    }
337    // Right-to-left DP over byte offsets. Invariant: `parse_counts[i]` is the number of ways
338    // `text[i..]` segments into vocabulary units, saturated at 2 — 0 = impossible, 1 = exactly
339    // one segmentation, 2 = "two or more", i.e. ambiguous. The empty suffix has one (the empty)
340    // segmentation, hence `parse_counts[n] = 1`. `choices[i]` is the unit that starts `text[i..]`
341    // in its unique parse, plus where that unit ends.
342    let n = text.len();
343    let mut parse_counts = vec![0u8; n + 1];
344    let mut choices: Vec<Option<(&'v str, usize)>> = vec![None; n + 1];
345    parse_counts[n] = 1;
346    for offset in (0..n).rev() {
347        if !text.is_char_boundary(offset) {
348            continue; // inside a multi-byte char: no unit can start here (count stays 0)
349        }
350        let mut count = 0u8;
351        let mut choice = None;
352        for unit in vocabulary.iter() {
353            let end = offset + unit.len();
354            if end > n || !text[offset..].starts_with(unit) || parse_counts[end] == 0 {
355                continue;
356            }
357            if count == 0 && parse_counts[end] == 1 {
358                choice = Some((*unit, end));
359            } else {
360                choice = None;
361            }
362            count = (count + parse_counts[end]).min(2);
363            if count == 2 {
364                break;
365            }
366        }
367        parse_counts[offset] = count;
368        if count == 1 {
369            // `count` lands on exactly 1 only when a single match contributed, and that match
370            // took the `count == 0 && parse_counts[end] == 1` branch — any second match would
371            // both clear `choice` and push `count` to 2. So `choice` is `Some` here, which is
372            // what the `expect` below relies on.
373            choices[offset] = choice;
374        }
375    }
376    match parse_counts[0] {
377        0 => Err(Error::UnknownWrittenUnit {
378            index: 0,
379            unit: text.to_owned(),
380        }),
381        1 => {
382            let mut units = Vec::new();
383            let mut offset = 0;
384            while offset < n {
385                let (unit, end) = choices[offset].expect("a unique parse records its choice");
386                units.push(unit);
387                offset = end;
388            }
389            Ok(units)
390        }
391        _ => Err(Error::InvalidUnitSpec(
392            "compact written-unit sequence is ambiguous; separate units with '+'".to_owned(),
393        )),
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn ambiguous_compact_units_require_plus_separators() {
403        // Python: `_parse_written_units("AAA", {"A", "AA"})` → "ambiguous … +"
404        let error = parse_unit_names("AAA", &["AA", "A"]).unwrap_err();
405        assert!(
406            matches!(&error, Error::InvalidUnitSpec(m) if m.contains("ambiguous") && m.contains('+'))
407        );
408        assert_eq!(parse_unit_names("AA", &["AA", "A"]).unwrap_err(), error);
409        assert_eq!(
410            parse_unit_names("AAB", &["AA", "A", "B"]).unwrap_err(),
411            error
412        );
413        assert_eq!(parse_unit_names("AAB", &["AA", "B"]).unwrap(), ["AA", "B"]);
414    }
415
416    /// The `(-len, name)` order is an optimisation, not semantics: any permutation of the same
417    /// vocabulary yields the same result.
418    #[test]
419    fn vocabulary_order_does_not_change_the_parse() {
420        // {AA, B} parses "AAB" uniquely; {A, AA} makes "AAA" ambiguous. Both hold in any order.
421        for vocabulary in [["AA", "B"], ["B", "AA"]] {
422            assert_eq!(parse_unit_names("AAB", &vocabulary).unwrap(), ["AA", "B"]);
423        }
424        for vocabulary in [["AA", "A"], ["A", "AA"]] {
425            assert!(parse_unit_names("AAA", &vocabulary).is_err());
426            assert_eq!(parse_unit_names("A", &vocabulary).unwrap(), ["A"]);
427        }
428    }
429
430    #[test]
431    fn multibyte_input_never_splits_a_char() {
432        assert_eq!(
433            parse_unit_names("A\u{1820}", &["A"]).unwrap_err(),
434            Error::UnknownWrittenUnit {
435                index: 0,
436                unit: "A\u{1820}".to_owned()
437            }
438        );
439    }
440
441    #[test]
442    fn trailing_transport_newline_is_stripped_once() {
443        assert_eq!(parse_unit_names("A\r\n", &["A"]).unwrap(), ["A"]);
444        assert_eq!(parse_unit_names("A\n", &["A"]).unwrap(), ["A"]);
445        assert_eq!(parse_unit_names("\n", &["A"]).unwrap(), Vec::<&str>::new());
446        assert!(parse_unit_names("A\n\n", &["A"]).is_err());
447    }
448}