Skip to main content

mongol_norm/
error.rs

1//! The crate's single error type. `Display` wording mirrors the Python exceptions so the CLI
2//! prints the same messages as the Python CLI.
3
4use std::fmt;
5
6use crate::{UnitPosition, WrittenUnit};
7
8/// Everything that can go wrong in this crate.
9#[non_exhaustive]
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Error {
12    /// `shape()` / `normalize()` input contains a character that is not a Mongolian letter,
13    /// FVS, MVS, NNBSP, nirugu or ZWJ. `index` is a `char` index (not a byte offset).
14    NonMongolianChar {
15        /// The offending character.
16        ch: char,
17        /// Its `char` index in the input.
18        index: usize,
19    },
20    /// `FontShaper::shape`: a character of the Mongolian block the bundled Hudum font has no
21    /// glyph for — a letter of another writing system, an Ali Gali sign, an unassigned code
22    /// point. `index` is a `char` index.
23    UnsupportedChar {
24        /// The offending character.
25        ch: char,
26        /// Its `char` index in the input.
27        index: usize,
28    },
29    /// Strict normalization found no canonical encoding for the written-unit shape
30    /// (Python `NormalizationFallbackError`).
31    NormalizationFallback {
32        /// The input text.
33        text: String,
34        /// Its shape, which the normalize table could not encode.
35        written_units: Vec<WrittenUnit>,
36    },
37    /// `parse_written_units`: a name that is neither a unit known to the normalize table nor
38    /// `Mvs`/`Nirugu`/`Zwj`.
39    UnknownWrittenUnit {
40        /// Index of the unit in the parsed sequence.
41        index: usize,
42        /// The unparseable name.
43        unit: String,
44    },
45    /// `normalize_written_units`: a written unit this shaper's normalize table does not know.
46    UnsupportedWrittenUnit {
47        /// Index of the unit in the input sequence.
48        index: usize,
49        /// The unsupported unit.
50        unit: WrittenUnit,
51    },
52    /// `normalize_written_units`: the sequence has no canonical MNG encoding (or the candidate
53    /// does not reshape to exactly the requested sequence).
54    NoCanonicalEncoding,
55    /// `normalize_positioned_written_units`: explicit `Zwj` records are rejected.
56    ExplicitZwj,
57    /// `normalize_positioned_written_units`: `Mvs` / `Nirugu` need [`UnitPosition::Control`].
58    ControlRequiresControlPosition {
59        /// Index of the record.
60        index: usize,
61        /// The control unit.
62        unit: WrittenUnit,
63    },
64    /// `normalize_positioned_written_units`: `(unit, position)` is not in the HUD inventory
65    /// (a letter with `Control` lands here too).
66    UnsupportedPositionedUnit {
67        /// Index of the record.
68        index: usize,
69        /// The unit.
70        unit: WrittenUnit,
71        /// The requested position.
72        position: UnitPosition,
73    },
74    /// `normalize_positioned_written_units`: the records do not form a valid init…fina chain in
75    /// the supplied joining context.
76    ChainPositionMismatch,
77    /// `normalize_positioned_written_units`: more than the maximum number of records.
78    TooManyRecords {
79        /// The maximum accepted.
80        max: usize,
81    },
82    /// `parse_written_units`: empty unit, whitespace, or an ambiguous compact segmentation.
83    InvalidUnitSpec(String),
84    /// A contract name (`Locale`, `Position`, `WrittenUnit`, …) failed to parse.
85    UnknownName {
86        /// What kind of name was expected (`"locale"`, `"written unit"`, …).
87        kind: &'static str,
88        /// The rejected text.
89        name: String,
90    },
91}
92
93impl fmt::Display for Error {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            // `escape_debug` mirrors Python's `repr(ch)` for control characters only: a newline
97            // prints as `\n`, a printable character as itself. Quotes and non-printable
98            // non-ASCII are spelled differently — Rust escapes `'` as `\'` and writes `\u{85}`
99            // where Python switches the quote style and writes `\x85`.
100            Error::NonMongolianChar { ch, index } => write!(
101                f,
102                "non-Mongolian character '{}' (U+{:04X}) at index {}: shape() / normalize() \
103                 accept only Mongolian letters + FVS/MVS/NNBSP/Nirugu/ZWJ. For mixed-script \
104                 input use normalize_text().",
105                ch.escape_debug(),
106                *ch as u32,
107                index
108            ),
109            Error::UnsupportedChar { ch, index } => write!(
110                f,
111                "unsupported character '{}' (U+{:04X}) at index {}: the shaping font has no \
112                 written form for it",
113                ch.escape_debug(),
114                *ch as u32,
115                index
116            ),
117            Error::NormalizationFallback { written_units, .. } => {
118                let names: Vec<&str> = written_units.iter().map(|u| u.as_str()).collect();
119                write!(
120                    f,
121                    "normalization fallback: no canonical encoding for written units {}",
122                    names.join("+")
123                )
124            }
125            Error::UnknownWrittenUnit { index, unit } => {
126                // `unit` is arbitrary user text (Python renders it with `repr()`), so escape it —
127                // a control character must never reach a terminal raw. Printable ASCII, which
128                // every real unit name is, passes through unchanged. As above, the escaping
129                // matches Python's `repr()` for control characters only; quotes and
130                // non-printable non-ASCII use Rust's `\'` / `\u{…}` spelling instead.
131                write!(
132                    f,
133                    "written_units[{index}] is unknown: '{}'",
134                    unit.escape_debug()
135                )
136            }
137            Error::UnsupportedWrittenUnit { index, unit } => {
138                write!(f, "written_units[{index}] is unknown: '{}'", unit.as_str())
139            }
140            Error::NoCanonicalEncoding => {
141                f.write_str("written-unit sequence has no canonical MNG encoding")
142            }
143            Error::ExplicitZwj => f.write_str("unsupported positioned control 'Zwj'"),
144            Error::ControlRequiresControlPosition { index, unit } => write!(
145                f,
146                "positioned_units[{index}] control '{}' requires position 'control'",
147                unit.as_str()
148            ),
149            Error::UnsupportedPositionedUnit { unit, position, .. } => write!(
150                f,
151                "unsupported positioned written unit '{}:{}'",
152                unit.as_str(),
153                position.as_str()
154            ),
155            Error::ChainPositionMismatch => f.write_str(
156                "positioned written-unit sequence has no canonical MNG encoding in the \
157                 supplied context",
158            ),
159            Error::TooManyRecords { max } => {
160                write!(f, "positioned_units accepts at most {max} records")
161            }
162            Error::InvalidUnitSpec(message) => f.write_str(message),
163            Error::UnknownName { kind, name } => write!(f, "unknown {kind} '{name}'"),
164        }
165    }
166}
167
168impl std::error::Error for Error {}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    /// These strings are a byte-exact contract: later CLI tests assert on them directly, the
175    /// same way the Python CLI's own error messages are asserted on.
176    #[test]
177    fn display_wording_matches_python() {
178        assert_eq!(
179            Error::NonMongolianChar { ch: 'x', index: 3 }.to_string(),
180            "non-Mongolian character 'x' (U+0078) at index 3: shape() / normalize() accept only \
181             Mongolian letters + FVS/MVS/NNBSP/Nirugu/ZWJ. For mixed-script input use \
182             normalize_text()."
183        );
184        // Python's `{ch!r}` escapes a control character; so must ours (`repr('\n')` == r"'\n'").
185        assert_eq!(
186            Error::NonMongolianChar { ch: '\n', index: 4 }.to_string(),
187            "non-Mongolian character '\\n' (U+000A) at index 4: shape() / normalize() accept only \
188             Mongolian letters + FVS/MVS/NNBSP/Nirugu/ZWJ. For mixed-script input use \
189             normalize_text()."
190        );
191        // An unassigned code point is not printable, so `escape_debug` spells it out (as
192        // Python's `repr` would, with its own `\u181a` spelling).
193        assert_eq!(
194            Error::UnsupportedChar {
195                ch: '\u{181A}',
196                index: 2
197            }
198            .to_string(),
199            "unsupported character '\\u{181a}' (U+181A) at index 2: the shaping font has no \
200             written form for it"
201        );
202        assert_eq!(
203            Error::UnsupportedChar {
204                ch: '\u{1878}',
205                index: 0
206            }
207            .to_string(),
208            "unsupported character '\u{1878}' (U+1878) at index 0: the shaping font has no written \
209             form for it"
210        );
211        assert_eq!(
212            Error::NormalizationFallback {
213                text: "t".into(),
214                written_units: vec![
215                    WrittenUnit::S,
216                    WrittenUnit::A,
217                    WrittenUnit::I,
218                    WrittenUnit::I,
219                    WrittenUnit::A,
220                ],
221            }
222            .to_string(),
223            "normalization fallback: no canonical encoding for written units S+A+I+I+A"
224        );
225        assert_eq!(
226            Error::UnknownWrittenUnit {
227                index: 1,
228                unit: "Unknown".into()
229            }
230            .to_string(),
231            "written_units[1] is unknown: 'Unknown'"
232        );
233        // A name is arbitrary user text; control characters are escaped, never emitted raw.
234        assert_eq!(
235            Error::UnknownWrittenUnit {
236                index: 0,
237                unit: "A\0B".into()
238            }
239            .to_string(),
240            "written_units[0] is unknown: 'A\\0B'"
241        );
242        assert_eq!(
243            Error::UnsupportedWrittenUnit {
244                index: 1,
245                unit: WrittenUnit::B
246            }
247            .to_string(),
248            "written_units[1] is unknown: 'B'"
249        );
250        assert_eq!(
251            Error::NoCanonicalEncoding.to_string(),
252            "written-unit sequence has no canonical MNG encoding"
253        );
254        assert_eq!(
255            Error::ExplicitZwj.to_string(),
256            "unsupported positioned control 'Zwj'"
257        );
258        assert_eq!(
259            Error::ControlRequiresControlPosition {
260                index: 0,
261                unit: WrittenUnit::Mvs
262            }
263            .to_string(),
264            "positioned_units[0] control 'Mvs' requires position 'control'"
265        );
266        assert_eq!(
267            Error::UnsupportedPositionedUnit {
268                index: 0,
269                unit: WrittenUnit::F,
270                position: UnitPosition::Isol,
271            }
272            .to_string(),
273            "unsupported positioned written unit 'F:isol'"
274        );
275        assert_eq!(
276            Error::ChainPositionMismatch.to_string(),
277            "positioned written-unit sequence has no canonical MNG encoding in the supplied \
278             context"
279        );
280        assert_eq!(
281            Error::TooManyRecords { max: 1024 }.to_string(),
282            "positioned_units accepts at most 1024 records"
283        );
284        assert_eq!(Error::InvalidUnitSpec("x".into()).to_string(), "x");
285        assert_eq!(
286            Error::UnknownName {
287                kind: "locale",
288                name: "XX".into()
289            }
290            .to_string(),
291            "unknown locale 'XX'"
292        );
293    }
294}