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