use std::borrow::Cow;
use mf2_catalog::Manifest;
use mf2_model::{Message, Pattern, PatternMessage, PatternPart, SelectMessage};
pub const RLO: char = '\u{202e}';
pub const PDF: char = '\u{202c}';
const ACCENTS_LOWER: [&str; 26] = [
"å", "ƀ", "ç", "ð", "é", "ƒ", "ĝ", "ĥ", "î", "ĵ", "ķ", "ļ", "ɱ", "ñ", "ö", "þ", "ǫ", "ŕ", "š",
"ţ", "û", "ṽ", "ŵ", "ẋ", "ý", "ž",
];
const ACCENTS_UPPER: [&str; 26] = [
"Å", "Ɓ", "Ç", "Ð", "É", "Ƒ", "Ĝ", "Ĥ", "Î", "Ĵ", "Ķ", "Ļ", "Ṁ", "Ñ", "Ö", "Þ", "Ǫ", "Ŕ", "Š",
"Ţ", "Û", "Ṽ", "Ŵ", "Ẋ", "Ý", "Ž",
];
const PAD: [&str; 10] = [
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
];
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind {
Accented,
RightToLeft,
}
impl Kind {
pub fn tag(self) -> &'static str {
match self {
Kind::Accented => "en-XA",
Kind::RightToLeft => "ar-XB",
}
}
pub fn from_tag(tag: &str) -> Option<Kind> {
match tag {
"en-XA" => Some(Kind::Accented),
"ar-XB" => Some(Kind::RightToLeft),
_ => None,
}
}
pub const ALL: [Kind; 2] = [Kind::Accented, Kind::RightToLeft];
}
pub fn names(tags: &[String], manifest: &Manifest) -> Vec<(String, Message<'static>)> {
let has = |id: &str, argument_free: bool| {
manifest
.ids
.binary_search_by(|i| i.as_str().cmp(id))
.is_ok_and(|at| !argument_free || manifest.slots.get(at).is_some_and(Vec::is_empty))
};
let named = tags
.iter()
.any(|tag| Kind::from_tag(tag).is_none() && has(&["language.", tag].concat(), true));
if !named {
return Vec::new();
}
Kind::ALL
.iter()
.filter(|kind| tags.iter().any(|tag| tag == kind.tag()))
.map(|kind| (["language.", kind.tag()].concat(), kind.tag()))
.filter(|(id, _)| !has(id, false))
.filter_map(|(id, tag)| Some((id, mf2_syntax::parse_model(tag).message?)))
.collect()
}
pub fn message<'a>(message: &Message<'a>, kind: Kind) -> Message<'a> {
match message {
Message::Pattern(m) => Message::Pattern(PatternMessage {
declarations: m.declarations.clone(),
pattern: pattern(&m.pattern, kind),
}),
Message::Select(m) => Message::Select(SelectMessage {
declarations: m.declarations.clone(),
selectors: m.selectors.clone(),
variants: m
.variants
.iter()
.map(|v| mf2_model::Variant {
keys: v.keys.clone(),
value: pattern(&v.value, kind),
})
.collect(),
}),
other => other.clone(),
}
}
fn pattern<'a>(pattern: &Pattern<'a>, kind: Kind) -> Pattern<'a> {
match kind {
Kind::Accented => accented(pattern),
Kind::RightToLeft => right_to_left(pattern),
}
}
fn text_bytes(pattern: &Pattern<'_>) -> usize {
pattern
.into_iter()
.map(|part| match part {
PatternPart::Text(t) => t.len(),
_ => 0,
})
.sum()
}
fn accented<'a>(source: &Pattern<'a>) -> Pattern<'a> {
let bytes = text_bytes(source);
let mut parts: Vec<PatternPart<'a>> = vec![PatternPart::Text(Cow::Borrowed("["))];
for part in source {
parts.push(match part {
PatternPart::Text(t) => PatternPart::Text(Cow::Owned(accent(t))),
other => other.clone(),
});
}
let mut pad = String::new();
let mut k = 0usize;
while pad.len() * 10 < bytes * 3 || pad.is_empty() {
pad.push(' ');
pad.push_str(PAD[k % PAD.len()]);
k += 1;
}
pad.push(']');
parts.push(PatternPart::Text(Cow::Owned(pad)));
merge_text(parts)
}
fn right_to_left<'a>(source: &Pattern<'a>) -> Pattern<'a> {
source
.into_iter()
.map(|part| match part {
PatternPart::Text(t) if !t.trim().is_empty() => {
PatternPart::Text(Cow::Owned(format!("{RLO}{t}{PDF}")))
}
other => other.clone(),
})
.collect()
}
pub fn accent(text: &str) -> String {
let mut out = String::with_capacity(text.len() * 2);
for c in text.chars() {
match c {
'a'..='z' => out.push_str(ACCENTS_LOWER[(c as usize) - ('a' as usize)]),
'A'..='Z' => out.push_str(ACCENTS_UPPER[(c as usize) - ('A' as usize)]),
_ => out.push(c),
}
}
out
}
fn merge_text<'a>(parts: Vec<PatternPart<'a>>) -> Pattern<'a> {
let mut out: Vec<PatternPart<'a>> = Vec::with_capacity(parts.len());
for part in parts {
match (out.last_mut(), part) {
(Some(PatternPart::Text(previous)), PatternPart::Text(text)) => {
previous.to_mut().push_str(&text);
}
(_, part) => out.push(part),
}
}
Pattern::from(out)
}
#[cfg(test)]
mod tests {
use super::{Kind, accent, message};
fn round(source: &str, kind: Kind) -> String {
let model = mf2_syntax::parse_model(source)
.message
.expect("the source parses");
mf2_syntax::serialize(&message(&model, kind)).expect("it serializes")
}
#[test]
fn accented_text_is_bracketed_and_padded() {
let out = round("Save", Kind::Accented);
assert!(out.starts_with("[Šåṽé"), "{out}");
assert!(out.ends_with(']'), "{out}");
assert!(out.len() > "Save".len() * 2, "{out}");
assert_eq!(accent("Az"), "Åž");
assert_eq!(accent("{$x} 1!"), "{$ẋ} 1!");
}
#[test]
fn a_placeholder_is_never_touched() {
let out = round("Hello, {$name}!", Kind::Accented);
assert!(out.contains("{$name}"), "{out}");
let out = round("Hello, {$name}!", Kind::RightToLeft);
assert!(out.contains("{$name}"), "{out}");
}
#[test]
fn right_to_left_wraps_every_text_run() {
let out = round("Hello, {$name}!", Kind::RightToLeft);
assert_eq!(out, "\u{202e}Hello, \u{202c}{$name}\u{202e}!\u{202c}");
let out = round("{$a} {$b}", Kind::RightToLeft);
assert_eq!(out, "{$a} {$b}");
}
#[test]
fn every_variant_of_a_select_is_localized() {
let out = round(
".input {$n :integer}\n.match $n\none {{one item}}\n* {{{$n} items}}",
Kind::RightToLeft,
);
assert!(out.contains("\u{202e}one item\u{202c}"), "{out}");
assert!(out.contains("\u{202e} items\u{202c}"), "{out}");
assert!(out.contains(".input {$n :integer}"), "{out}");
assert!(out.contains("one {{"), "{out}");
}
}