use crate::code_type::{CodeSeries, CodeType};
use crate::dispatch::{letter_from_rule, letter_to_rule, shape_from_rule, shape_to_rule};
use crate::error::MongolConvertError;
use crate::letter::from_translator::LetterFromTranslator;
use crate::letter::rule::WORD_CONNECTOR;
use crate::letter::to_translator::LetterToTranslator;
use crate::shape::punctuation_gap;
use crate::shape::softbank_emoji;
use crate::shape::translator::ShapeTranslator;
use crate::strings;
use crate::unicode::zvvnmod::is_zvvnmod_code;
use crate::utn57_shape;
use std::borrow::Cow;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Warning {
Utn57(String),
RepairedSuffixSeparator { byte_offset: usize, original: char },
}
impl fmt::Display for Warning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Warning::Utn57(reason) => write!(f, "UTN #57: {reason}"),
Warning::RepairedSuffixSeparator { byte_offset, original } => write!(
f,
"repaired possible suffix separator at input byte {byte_offset}: U+{:04X} -> U+202F",
*original as u32
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Translation {
pub text: String,
pub warnings: Vec<Warning>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TranslationOptions {
pub repair_suffix_separators: bool,
pub restore_menk_shape_emoji: bool,
}
impl Default for TranslationOptions {
fn default() -> Self {
Self {
repair_suffix_separators: false,
restore_menk_shape_emoji: true,
}
}
}
pub fn translate_with_options(
from: CodeType,
to: CodeType,
input: &str,
options: &TranslationOptions,
) -> Result<Translation, MongolConvertError> {
let (input, mut warnings) = if options.repair_suffix_separators {
crate::repair::suffix_separators(from, input)?
} else {
(Cow::Borrowed(input), Vec::new())
};
let mut result = translate_inner(from, to, &input, options)?;
warnings.append(&mut result.warnings);
result.warnings = warnings;
Ok(result)
}
impl Translation {
fn plain(text: String) -> Self {
Self {
text,
warnings: Vec::new(),
}
}
}
pub fn translate(from: CodeType, to: CodeType, input: &str) -> Result<String, MongolConvertError> {
translate_with_warnings(from, to, input).map(|translation| translation.text)
}
pub fn translate_with_warnings(
from: CodeType,
to: CodeType,
input: &str,
) -> Result<Translation, MongolConvertError> {
translate_inner(from, to, input, &TranslationOptions::default())
}
fn translate_inner(
from: CodeType,
to: CodeType,
input: &str,
options: &TranslationOptions,
) -> Result<Translation, MongolConvertError> {
if strings::is_blank(input) {
return Ok(Translation::plain(input.to_string()));
}
if from == to {
return Ok(Translation::plain(
normalize_menk_shape_source(from, input, options).into_owned(),
));
}
let hub = if from == CodeType::Zvvnmod {
input.to_string()
} else {
translate_from(from, input, options)?
};
if to == CodeType::Zvvnmod {
return Ok(Translation::plain(hub));
}
translate_to(to, &hub)
}
const HUB_NIRUGU: &str = "\u{E0E5}";
const UNICODE_NIRUGU: &str = "\u{180A}";
const HUB_G_O_ISOL: char = '\u{E096}';
const LEGACY_G_O_FINA: char = '\u{E09C}';
fn promote_word_initial_g_o(hub: &str) -> String {
let chars: Vec<char> = hub.chars().collect();
let mut out = String::with_capacity(hub.len());
for (index, &c) in chars.iter().enumerate() {
if c == LEGACY_G_O_FINA && !joined_on_the_left(&chars[..index]) {
out.push(HUB_G_O_ISOL);
} else {
out.push(c);
}
}
out
}
fn joined_on_the_left(before: &[char]) -> bool {
before
.iter()
.rev()
.find(|c| !is_transparent_mark(**c))
.is_some_and(|c| joins_to_the_right(*c))
}
fn is_transparent_mark(c: char) -> bool {
matches!(c, '\u{180B}'..='\u{180D}' | '\u{E140}'..='\u{E144}')
}
fn joins_to_the_right(c: char) -> bool {
is_zvvnmod_code(c) || matches!(c, HUB_G_O_ISOL | '\u{E0E5}' | '\u{180A}' | '\u{200D}')
}
fn normalize_menk_shape_source<'a>(
ct: CodeType,
s: &'a str,
options: &TranslationOptions,
) -> Cow<'a, str> {
if ct == CodeType::MenkShape && options.restore_menk_shape_emoji {
softbank_emoji::restore_menk_shape(s)
} else {
Cow::Borrowed(s)
}
}
fn translate_from(
ct: CodeType,
s: &str,
options: &TranslationOptions,
) -> Result<String, MongolConvertError> {
if ct == CodeType::Oyun {
return Err(MongolConvertError::Unsupported(ct));
}
if ct == CodeType::Utn57Shape {
let utn57 = utn57_shape::decode(s)?;
return translate_from(CodeType::Utn57, &utn57, options);
}
if ct == CodeType::Utn57 {
return zvvnmod_utn57::convert_utn57_to_zvvnmod(s)
.map_err(|error| MongolConvertError::Utn57(error.to_string()));
}
let hub = match ct.code_series() {
CodeSeries::Shape => {
let source = normalize_menk_shape_source(ct, s, options);
let plain = match punctuation_gap::of(ct) {
Some(gap) => gap.strip(&source),
None => source.into_owned(),
};
ShapeTranslator::new(shape_from_rule(ct)?).translate(&plain)?
}
CodeSeries::Letter => LetterFromTranslator::new(letter_from_rule(ct)?).translate(s)?,
};
Ok(promote_word_initial_g_o(
&hub.replace(UNICODE_NIRUGU, HUB_NIRUGU),
))
}
fn translate_to(ct: CodeType, s: &str) -> Result<Translation, MongolConvertError> {
if ct == CodeType::Oyun {
return Err(MongolConvertError::Unsupported(ct));
}
if ct == CodeType::Utn57Shape {
let utn57 = translate_to(CodeType::Utn57, s)?;
return Ok(Translation {
text: utn57_shape::encode(&utn57.text)?,
warnings: utn57.warnings,
});
}
let hub = s.replace(UNICODE_NIRUGU, HUB_NIRUGU);
if ct == CodeType::Utn57 {
let conversion = zvvnmod_utn57::convert_zvvnmod_to_utn57_with_warnings(&hub)
.map_err(|error| MongolConvertError::Utn57(error.to_string()))?;
return Ok(Translation {
text: conversion.text,
warnings: conversion
.warnings
.iter()
.map(|warning| Warning::Utn57(warning.to_string()))
.collect(),
});
}
let legacy = hub
.replace(HUB_NIRUGU, UNICODE_NIRUGU)
.replace(HUB_G_O_ISOL, &LEGACY_G_O_FINA.to_string());
let text = match ct.code_series() {
CodeSeries::Shape => {
let flattened = legacy.replace(WORD_CONNECTOR, " ");
let shaped = ShapeTranslator::new(shape_to_rule(ct)?).translate(&flattened)?;
match punctuation_gap::of(ct) {
Some(gap) => gap.insert(&shaped),
None => shaped,
}
}
CodeSeries::Letter => LetterToTranslator::new(letter_to_rule(ct)?).translate(&legacy)?,
};
Ok(Translation::plain(text))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_hub_g_o_isol_is_the_utn57_crate_inventory_code() {
assert_eq!(u32::from(HUB_G_O_ISOL), zvvnmod_utn57::G_O_ISOL.0);
assert_eq!(u32::from(LEGACY_G_O_FINA), zvvnmod_utn57::G_O_FINA.0);
}
#[test]
fn only_a_word_initial_g_o_is_promoted() {
let cases = [
("\u{E09C}", "\u{E096}"),
(" \u{E09C} ", " \u{E096} "),
("\u{1802}\u{E09C}", "\u{1802}\u{E096}"),
("\u{202F}\u{E09C}", "\u{202F}\u{E096}"),
("\u{E00C}\u{202F}\u{E09C}", "\u{E00C}\u{202F}\u{E096}"),
("\u{E000}\u{E005}\u{E09C}", "\u{E000}\u{E005}\u{E09C}"),
("\u{E0E5}\u{E09C}", "\u{E0E5}\u{E09C}"),
("\u{180A}\u{E09C}", "\u{180A}\u{E09C}"),
("\u{200D}\u{E09C}", "\u{200D}\u{E09C}"),
("\u{E006}\u{E140}\u{E09C}", "\u{E006}\u{E140}\u{E09C}"),
("\u{E006}\u{180B}\u{E09C}", "\u{E006}\u{180B}\u{E09C}"),
("\u{180B}\u{E09C}", "\u{180B}\u{E096}"),
("\u{E096}", "\u{E096}"),
("\u{E093}", "\u{E093}"),
];
for (hub, expected) in cases {
assert_eq!(promote_word_initial_g_o(hub), expected, "{hub:?}");
}
}
#[test]
fn the_hub_nirugu_is_the_utn57_crate_inventory_code() {
let hub = HUB_NIRUGU.chars().next().unwrap();
assert_eq!(u32::from(hub), zvvnmod_utn57::NIRUGU.0);
assert_eq!(UNICODE_NIRUGU, "\u{180A}");
}
}