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}