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
260fn strip_one_newline(text: &str) -> &str {
261 text.strip_suffix("\r\n")
262 .or_else(|| text.strip_suffix('\n'))
263 .or_else(|| text.strip_suffix('\r'))
264 .unwrap_or(text)
265}
266
267/// Python `_parse_written_units`: explicit `+` names, or a unique compact segmentation over
268/// `vocabulary`. Returns borrowed names — slices of `text` on the `+` path, vocabulary entries on
269/// the compact path — which the caller maps to units.
270///
271/// `vocabulary` is expected in Python's `(-len, name)` order (see
272/// [`crate::normalize::NormalizeTable::sorted_vocabulary`]), but that order is *not* semantically load-bearing: the
273/// per-offset count saturates at 2 and `choice` is cleared by any second match, so the final
274/// `(count, choice)` pair is independent of iteration order. Sorting only lets the longest
275/// candidates hit the `count == 2` early `break` sooner.
276pub(crate) fn parse_unit_names<'v>(
277 text: &'v str,
278 vocabulary: &[&'v str],
279) -> Result<Vec<&'v str>, Error> {
280 let text = strip_one_newline(text);
281 if text.is_empty() {
282 return Ok(Vec::new());
283 }
284 if text.chars().any(char::is_whitespace) {
285 return Err(Error::InvalidUnitSpec(
286 "written units cannot be empty or contain whitespace".to_owned(),
287 ));
288 }
289 if text.contains('+') {
290 let units: Vec<&str> = text.split('+').collect();
291 if units
292 .iter()
293 .any(|unit| unit.is_empty() || *unit != unit.trim())
294 {
295 return Err(Error::InvalidUnitSpec(
296 "written units cannot be empty or contain whitespace; separate explicit units \
297 with '+' (for example A+Aa+B+Zwj)"
298 .to_owned(),
299 ));
300 }
301 return Ok(units);
302 }
303 // Right-to-left DP over byte offsets. Invariant: `parse_counts[i]` is the number of ways
304 // `text[i..]` segments into vocabulary units, saturated at 2 — 0 = impossible, 1 = exactly
305 // one segmentation, 2 = "two or more", i.e. ambiguous. The empty suffix has one (the empty)
306 // segmentation, hence `parse_counts[n] = 1`. `choices[i]` is the unit that starts `text[i..]`
307 // in its unique parse, plus where that unit ends.
308 let n = text.len();
309 let mut parse_counts = vec![0u8; n + 1];
310 let mut choices: Vec<Option<(&'v str, usize)>> = vec![None; n + 1];
311 parse_counts[n] = 1;
312 for offset in (0..n).rev() {
313 if !text.is_char_boundary(offset) {
314 continue; // inside a multi-byte char: no unit can start here (count stays 0)
315 }
316 let mut count = 0u8;
317 let mut choice = None;
318 for unit in vocabulary.iter() {
319 let end = offset + unit.len();
320 if end > n || !text[offset..].starts_with(unit) || parse_counts[end] == 0 {
321 continue;
322 }
323 if count == 0 && parse_counts[end] == 1 {
324 choice = Some((*unit, end));
325 } else {
326 choice = None;
327 }
328 count = (count + parse_counts[end]).min(2);
329 if count == 2 {
330 break;
331 }
332 }
333 parse_counts[offset] = count;
334 if count == 1 {
335 // `count` lands on exactly 1 only when a single match contributed, and that match
336 // took the `count == 0 && parse_counts[end] == 1` branch — any second match would
337 // both clear `choice` and push `count` to 2. So `choice` is `Some` here, which is
338 // what the `expect` below relies on.
339 choices[offset] = choice;
340 }
341 }
342 match parse_counts[0] {
343 0 => Err(Error::UnknownWrittenUnit {
344 index: 0,
345 unit: text.to_owned(),
346 }),
347 1 => {
348 let mut units = Vec::new();
349 let mut offset = 0;
350 while offset < n {
351 let (unit, end) = choices[offset].expect("a unique parse records its choice");
352 units.push(unit);
353 offset = end;
354 }
355 Ok(units)
356 }
357 _ => Err(Error::InvalidUnitSpec(
358 "compact written-unit sequence is ambiguous; separate units with '+'".to_owned(),
359 )),
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn ambiguous_compact_units_require_plus_separators() {
369 // Python: `_parse_written_units("AAA", {"A", "AA"})` → "ambiguous … +"
370 let error = parse_unit_names("AAA", &["AA", "A"]).unwrap_err();
371 assert!(
372 matches!(&error, Error::InvalidUnitSpec(m) if m.contains("ambiguous") && m.contains('+'))
373 );
374 assert_eq!(parse_unit_names("AA", &["AA", "A"]).unwrap_err(), error);
375 assert_eq!(
376 parse_unit_names("AAB", &["AA", "A", "B"]).unwrap_err(),
377 error
378 );
379 assert_eq!(parse_unit_names("AAB", &["AA", "B"]).unwrap(), ["AA", "B"]);
380 }
381
382 /// The `(-len, name)` order is an optimisation, not semantics: any permutation of the same
383 /// vocabulary yields the same result.
384 #[test]
385 fn vocabulary_order_does_not_change_the_parse() {
386 // {AA, B} parses "AAB" uniquely; {A, AA} makes "AAA" ambiguous. Both hold in any order.
387 for vocabulary in [["AA", "B"], ["B", "AA"]] {
388 assert_eq!(parse_unit_names("AAB", &vocabulary).unwrap(), ["AA", "B"]);
389 }
390 for vocabulary in [["AA", "A"], ["A", "AA"]] {
391 assert!(parse_unit_names("AAA", &vocabulary).is_err());
392 assert_eq!(parse_unit_names("A", &vocabulary).unwrap(), ["A"]);
393 }
394 }
395
396 #[test]
397 fn multibyte_input_never_splits_a_char() {
398 assert_eq!(
399 parse_unit_names("A\u{1820}", &["A"]).unwrap_err(),
400 Error::UnknownWrittenUnit {
401 index: 0,
402 unit: "A\u{1820}".to_owned()
403 }
404 );
405 }
406
407 #[test]
408 fn trailing_transport_newline_is_stripped_once() {
409 assert_eq!(parse_unit_names("A\r\n", &["A"]).unwrap(), ["A"]);
410 assert_eq!(parse_unit_names("A\n", &["A"]).unwrap(), ["A"]);
411 assert_eq!(parse_unit_names("\n", &["A"]).unwrap(), Vec::<&str>::new());
412 assert!(parse_unit_names("A\n\n", &["A"]).is_err());
413 }
414}