use std::collections::HashMap;
use std::fmt::Display;
use std::sync::OnceLock;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Language {
English,
Serbian,
Turkish,
Norwegian,
German,
Italian,
Greek,
French,
Slovenian,
}
impl Language {
pub const ALL: [Language; 9] = [
Language::English,
Language::Serbian,
Language::Turkish,
Language::Norwegian,
Language::German,
Language::Italian,
Language::Greek,
Language::French,
Language::Slovenian,
];
pub fn key(self) -> &'static str {
match self {
Language::English => "en",
Language::Serbian => "sr",
Language::Turkish => "tr",
Language::Norwegian => "nb",
Language::German => "de",
Language::Italian => "it",
Language::Greek => "el",
Language::French => "fr",
Language::Slovenian => "sl",
}
}
pub fn label(self) -> &'static str {
match self {
Language::English => "English",
Language::Serbian => "Srpski (latinica)",
Language::Turkish => "Türkçe",
Language::Norwegian => "Norsk bokmål",
Language::German => "Deutsch",
Language::Italian => "Italiano",
Language::Greek => "Ελληνικά",
Language::French => "Français",
Language::Slovenian => "Slovenščina",
}
}
pub fn from_key(key: &str) -> Option<Language> {
Language::ALL.into_iter().find(|language| language.key() == key)
}
pub(crate) fn source(self) -> Option<&'static str> {
match self {
Language::English => None,
Language::Serbian => Some(include_str!("../lang/sr.lang")),
Language::Turkish => Some(include_str!("../lang/tr.lang")),
Language::Norwegian => Some(include_str!("../lang/nb.lang")),
Language::German => Some(include_str!("../lang/de.lang")),
Language::Italian => Some(include_str!("../lang/it.lang")),
Language::Greek => Some(include_str!("../lang/el.lang")),
Language::French => Some(include_str!("../lang/fr.lang")),
Language::Slovenian => Some(include_str!("../lang/sl.lang")),
}
}
pub fn plural(self, count: u64) -> Form {
match self {
Language::English | Language::Turkish | Language::Norwegian | Language::German | Language::Italian | Language::Greek => {
if count == 1 {
Form::One
} else {
Form::Other
}
}
Language::French => {
if count <= 1 {
Form::One
} else {
Form::Other
}
}
Language::Slovenian => match count % 100 {
1 => Form::One,
2 => Form::Two,
3 | 4 => Form::Few,
_ => Form::Other,
},
Language::Serbian => match (count % 10, count % 100) {
(1, tens) if tens != 11 => Form::One,
(2..=4, tens) if !(12..=14).contains(&tens) => Form::Few,
_ => Form::Other,
},
}
}
pub fn from_locale(locale: &str) -> Option<Language> {
let code = locale.split(['_', '-', '.', '@']).next().unwrap_or("").to_ascii_lowercase();
match code.as_str() {
"c" | "posix" | "en" => Some(Language::English),
"sr" => Some(Language::Serbian),
"tr" => Some(Language::Turkish),
"nb" | "no" => Some(Language::Norwegian),
"de" => Some(Language::German),
"it" => Some(Language::Italian),
"el" => Some(Language::Greek),
"fr" => Some(Language::French),
"sl" => Some(Language::Slovenian),
_ => None,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Form {
One,
Two,
Few,
Other,
}
impl Form {
fn from_key(key: &str) -> Option<Form> {
match key {
"one" => Some(Form::One),
"two" => Some(Form::Two),
"few" => Some(Form::Few),
"other" => Some(Form::Other),
_ => None,
}
}
}
pub fn detect() -> Language {
let var = |name: &str| std::env::var(name).ok().filter(|value| !value.is_empty());
let locale = ["LC_ALL", "LC_MESSAGES", "LANG"].into_iter().find_map(var);
let plain_c = locale.as_deref().is_none_or(|locale| locale == "C" || locale == "POSIX" || locale.starts_with("C."));
if !plain_c
&& let Some(list) = var("LANGUAGE")
&& let Some(language) = list.split(':').find_map(Language::from_locale)
{
return language;
}
locale.as_deref().and_then(Language::from_locale).unwrap_or(Language::English)
}
#[derive(Default, Debug)]
pub(crate) struct Catalog {
pub plain: HashMap<String, String>,
pub forms: HashMap<(String, Form), String>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct ParseError {
pub line: usize,
pub message: String,
}
pub(crate) fn parse(text: &str) -> Result<Catalog, ParseError> {
let mut catalog = Catalog::default();
let mut id: Option<String> = None;
let mut context: Option<String> = None;
let mut translated = false;
let error = |line: usize, message: &str| ParseError { line: line + 1, message: message.to_string() };
for (number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (keyword, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
let rest = unescape(rest.trim());
if keyword == "msgctxt" {
if id.is_some() && !translated {
return Err(error(number, "a msgctxt after a msgid with no msgstr"));
}
if rest.is_empty() {
return Err(error(number, "an empty msgctxt"));
}
context = Some(rest);
id = None;
continue;
}
if keyword == "msgid" {
if id.is_some() && !translated {
return Err(error(number, "a msgid with no msgstr before it"));
}
if rest.is_empty() {
return Err(error(number, "an empty msgid"));
}
id = Some(match context.take() {
Some(context) => keyed(&context, &rest),
None => rest,
});
translated = false;
continue;
}
if context.is_some() {
return Err(error(number, "a msgctxt with no msgid after it"));
}
let Some(current) = id.clone() else {
return Err(error(number, "a msgstr with no msgid before it"));
};
if rest.is_empty() {
return Err(error(number, "an empty msgstr"));
}
if keyword == "msgstr" {
if catalog.plain.insert(current, rest).is_some() {
return Err(error(number, "translated twice"));
}
} else if let Some(form) = keyword.strip_prefix("msgstr[").and_then(|form| form.strip_suffix(']')) {
let Some(form) = Form::from_key(form) else {
return Err(error(number, "a form that is not one, two, few or other"));
};
if catalog.forms.insert((current, form), rest).is_some() {
return Err(error(number, "translated twice"));
}
} else {
return Err(error(number, "neither msgid nor msgstr"));
}
translated = true;
}
if id.is_some() && !translated {
return Err(ParseError {
line: text.lines().count(),
message: "a msgid with no msgstr after it".to_string(),
});
}
Ok(catalog)
}
pub(crate) fn keyed(context: &str, english: &str) -> String {
format!("{context}\u{4}{english}")
}
fn unescape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
fn catalog(language: Language) -> Option<&'static Catalog> {
static CATALOGS: [OnceLock<Option<Catalog>>; Language::ALL.len()] = [const { OnceLock::new() }; Language::ALL.len()];
let index = Language::ALL.iter().position(|&known| known == language)?;
CATALOGS[index].get_or_init(|| language.source().and_then(|source| parse(source).ok())).as_ref()
}
#[cfg(not(test))]
mod current {
use super::Language;
use std::sync::atomic::{AtomicU8, Ordering};
static CURRENT: AtomicU8 = AtomicU8::new(0);
pub fn get() -> Language {
Language::ALL.get(CURRENT.load(Ordering::Relaxed) as usize).copied().unwrap_or(Language::English)
}
pub fn set(language: Language) {
let index = Language::ALL.iter().position(|&known| known == language).unwrap_or(0);
CURRENT.store(index as u8, Ordering::Relaxed);
}
}
#[cfg(test)]
mod current {
use super::Language;
use std::cell::Cell;
thread_local! {
static CURRENT: Cell<Language> = const { Cell::new(Language::English) };
}
pub fn get() -> Language {
CURRENT.with(Cell::get)
}
pub fn set(language: Language) {
CURRENT.with(|current| current.set(language));
}
}
pub fn language() -> Language {
current::get()
}
pub fn set_language(language: Language) {
current::set(language);
}
pub fn apply(choice: Option<Language>) {
set_language(choice.unwrap_or_else(detect));
}
pub fn tr(english: &'static str) -> &'static str {
let language = language();
if language == Language::English {
return english;
}
catalog(language).and_then(|catalog| catalog.plain.get(english)).map_or(english, String::as_str)
}
pub fn tr_context(context: &'static str, english: &'static str) -> &'static str {
let language = language();
if language == Language::English {
return english;
}
catalog(language).and_then(|catalog| catalog.plain.get(&keyed(context, english))).map_or(english, String::as_str)
}
pub fn tr_count(one: &'static str, other: &'static str, count: u64) -> &'static str {
let language = language();
let english = if count == 1 { one } else { other };
if language == Language::English {
return english;
}
let Some(catalog) = catalog(language) else {
return english;
};
let form = language.plural(count);
catalog.forms.get(&(one.to_string(), form)).or_else(|| catalog.forms.get(&(one.to_string(), Form::Other))).map_or(english, String::as_str)
}
pub fn fill(template: &str, values: &[(&str, &dyn Display)]) -> String {
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let value = after.find('}').and_then(|close| values.iter().find(|(name, _)| *name == &after[..close]).map(|(_, value)| (close, value)));
match value {
Some((close, value)) => {
use std::fmt::Write;
let _ = write!(out, "{value}");
rest = &after[close + 1..];
}
None => {
out.push('{');
rest = after;
}
}
}
out.push_str(rest);
out
}
macro_rules! t {
(@value $name:ident = $value:expr) => {
$value
};
(@value $name:ident) => {
$name
};
($text:literal) => {
$crate::i18n::tr($text)
};
($text:literal, $($name:ident $(= $value:expr)?),+ $(,)?) => {
$crate::i18n::fill($crate::i18n::tr($text), &[$((stringify!($name), &t!(@value $name $(= $value)?) as &dyn std::fmt::Display)),+])
};
}
macro_rules! tc {
($context:literal, $text:literal) => {
$crate::i18n::tr_context($context, $text)
};
}
macro_rules! tn {
($count:expr, $one:literal, $other:literal) => {
$crate::i18n::tr_count($one, $other, ($count) as u64)
};
($count:expr, $one:literal, $other:literal, $($name:ident $(= $value:expr)?),+ $(,)?) => {
$crate::i18n::fill($crate::i18n::tr_count($one, $other, ($count) as u64), &[$((stringify!($name), &t!(@value $name $(= $value)?) as &dyn std::fmt::Display)),+])
};
}
#[cfg(test)]
mod tests;