use crate::error::MongolConvertError;
use std::sync::OnceLock;
use zvvnmod_utn57::mongol_norm::{is_mongolian_word_char, Locale, Shaper};
fn shaper() -> &'static Shaper {
static SHAPER: OnceLock<Shaper> = OnceLock::new();
SHAPER.get_or_init(|| Shaper::new(Locale::Mng))
}
fn error(reason: impl std::fmt::Display) -> MongolConvertError {
MongolConvertError::Utn57(format!("utn57_shape: {reason}"))
}
pub(crate) fn encode(utn57: &str) -> Result<String, MongolConvertError> {
fn flush(word: &mut String, out: &mut String) -> Result<(), MongolConvertError> {
if word.is_empty() {
return Ok(());
}
for unit in shaper().shape(word).map_err(error)? {
out.push_str(unit.as_str());
}
word.clear();
Ok(())
}
let mut out = String::with_capacity(utn57.len());
let mut word = String::new();
for c in utn57.chars() {
if is_mongolian_word_char(c) {
word.push(c);
} else {
flush(&mut word, &mut out)?;
out.push(c);
}
}
flush(&mut word, &mut out)?;
Ok(out)
}
pub(crate) fn decode(shape: &str) -> Result<String, MongolConvertError> {
let mut out = String::with_capacity(shape.len() * 3);
for piece in split(shape) {
match piece {
Piece::Word(word) => {
let shaper = shaper();
let units = shaper.parse_written_units(word).map_err(error)?;
out.push_str(&shaper.normalize_written_units(&units).map_err(error)?);
}
Piece::Other(text) => out.push_str(text),
}
}
Ok(out)
}
#[derive(Debug, PartialEq, Eq)]
enum Piece<'a> {
Word(&'a str),
Other(&'a str),
}
fn is_word_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '+'
}
fn split(text: &str) -> Vec<Piece<'_>> {
let mut pieces = Vec::new();
let mut other_start: Option<usize> = None;
let mut index = 0;
while index < text.len() {
let rest = &text[index..];
let run = rest.find(|c: char| !is_word_char(c)).unwrap_or(rest.len());
if run > 0 && rest.starts_with(|c: char| c.is_ascii_uppercase()) {
if let Some(start) = other_start.take() {
pieces.push(Piece::Other(&text[start..index]));
}
pieces.push(Piece::Word(&rest[..run]));
index += run;
} else {
other_start.get_or_insert(index);
index += if run > 0 {
run
} else {
rest.chars().next().map_or(0, char::len_utf8)
};
}
}
if let Some(start) = other_start {
pieces.push(Piece::Other(&text[start..]));
}
pieces
}
#[cfg(test)]
mod tests {
use super::*;
const SAIN: &str = "\u{1830}\u{1820}\u{1822}\u{180D}\u{1822}\u{180D}\u{1820}\u{180C}";
const MONGOL_UN: &str = "\u{182E}\u{1823}\u{1820}\u{182D}\u{180C}\u{1828}\u{180B}\u{1828}\u{180B}\u{1823}\u{182F}\u{180E}\u{1824}\u{180B}\u{1820}\u{180C}";
#[test]
fn a_word_is_spelled_as_its_written_units() {
assert_eq!(encode(SAIN).unwrap(), "SAIIA");
assert_eq!(encode(MONGOL_UN).unwrap(), "MOAGNNOLMvsOA");
}
#[test]
fn compact_and_joined_input_reach_the_same_canonical_text() {
assert_eq!(decode("SAIIA").unwrap(), SAIN);
assert_eq!(decode("S+A+I+I+A").unwrap(), SAIN);
assert_eq!(decode("MOAGNNOLMvsOA").unwrap(), MONGOL_UN);
}
#[test]
fn text_outside_a_word_passes_through_both_ways() {
assert_eq!(
encode(&format!("{SAIN} {SAIN}\u{1802} 2024 hello")).unwrap(),
"SAIIA SAIIA\u{1802} 2024 hello"
);
assert_eq!(
decode("SAIIA SAIIA\u{1802} 2024 hello").unwrap(),
format!("{SAIN} {SAIN}\u{1802} 2024 hello")
);
assert_eq!(decode("").unwrap(), "");
assert_eq!(encode(" ").unwrap(), " ");
}
#[test]
fn an_uppercase_led_run_that_is_not_a_spelling_is_an_error() {
let error = decode("Hello").unwrap_err();
assert!(
error
.to_string()
.starts_with("UTN #57 conversion failed: utn57_shape:"),
"{error}"
);
assert!(matches!(decode("SAIIA Hello"), Err(MongolConvertError::Utn57(_))));
}
#[test]
fn words_are_uppercase_led_ascii_runs() {
assert_eq!(
split("SAIIA, 2024 hello S+A"),
vec![
Piece::Word("SAIIA"),
Piece::Other(", 2024 hello "),
Piece::Word("S+A")
]
);
assert_eq!(split(""), Vec::<Piece<'_>>::new());
assert_eq!(split("ᠠ"), vec![Piece::Other("ᠠ")]);
}
}