mod en;
mod message;
mod pt_br;
#[cfg(feature = "i18n-rtl")]
mod ar;
#[cfg(feature = "i18n-europe")]
mod de;
#[cfg(feature = "i18n-europe")]
mod es;
#[cfg(feature = "i18n-europe")]
mod fr;
#[cfg(feature = "i18n-rtl")]
mod he;
#[cfg(feature = "i18n-europe")]
mod it;
#[cfg(feature = "i18n-cjk")]
mod ja;
#[cfg(feature = "i18n-cjk")]
mod ko;
#[cfg(feature = "i18n-cjk")]
mod zh_hans;
#[cfg(feature = "i18n-cjk")]
mod zh_hant;
pub use message::Message;
use fluent_langneg::negotiate::{negotiate_languages, NegotiationStrategy};
use std::str::FromStr;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Language {
En,
PtBr,
#[cfg(feature = "i18n-europe")]
Es,
#[cfg(feature = "i18n-europe")]
Fr,
#[cfg(feature = "i18n-europe")]
De,
#[cfg(feature = "i18n-europe")]
It,
#[cfg(feature = "i18n-cjk")]
ZhHans,
#[cfg(feature = "i18n-cjk")]
ZhHant,
#[cfg(feature = "i18n-cjk")]
Ja,
#[cfg(feature = "i18n-cjk")]
Ko,
#[cfg(feature = "i18n-rtl")]
Ar,
#[cfg(feature = "i18n-rtl")]
He,
}
impl Language {
#[must_use]
pub fn as_tag(self) -> &'static str {
match self {
Language::En => "en",
Language::PtBr => "pt-BR",
#[cfg(feature = "i18n-europe")]
Language::Es => "es",
#[cfg(feature = "i18n-europe")]
Language::Fr => "fr",
#[cfg(feature = "i18n-europe")]
Language::De => "de",
#[cfg(feature = "i18n-europe")]
Language::It => "it",
#[cfg(feature = "i18n-cjk")]
Language::ZhHans => "zh-Hans",
#[cfg(feature = "i18n-cjk")]
Language::ZhHant => "zh-Hant",
#[cfg(feature = "i18n-cjk")]
Language::Ja => "ja",
#[cfg(feature = "i18n-cjk")]
Language::Ko => "ko",
#[cfg(feature = "i18n-rtl")]
Language::Ar => "ar",
#[cfg(feature = "i18n-rtl")]
Language::He => "he",
}
}
#[must_use]
pub fn compiled() -> &'static [Language] {
&[
Language::En,
Language::PtBr,
#[cfg(feature = "i18n-europe")]
Language::Es,
#[cfg(feature = "i18n-europe")]
Language::Fr,
#[cfg(feature = "i18n-europe")]
Language::De,
#[cfg(feature = "i18n-europe")]
Language::It,
#[cfg(feature = "i18n-cjk")]
Language::ZhHans,
#[cfg(feature = "i18n-cjk")]
Language::ZhHant,
#[cfg(feature = "i18n-cjk")]
Language::Ja,
#[cfg(feature = "i18n-cjk")]
Language::Ko,
#[cfg(feature = "i18n-rtl")]
Language::Ar,
#[cfg(feature = "i18n-rtl")]
Language::He,
]
}
#[must_use]
pub fn compiled_tags() -> String {
Language::compiled()
.iter()
.map(|l| l.as_tag())
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn from_tag(raw: &str) -> Option<Language> {
let requested = negotiable(raw)?;
let available: Vec<NegotiableId> = Language::compiled()
.iter()
.filter_map(|l| negotiable(l.as_tag()))
.collect();
let matched =
negotiate_languages(&[requested], &available, None, NegotiationStrategy::Lookup);
let winner = matched.first()?.to_string();
Language::compiled()
.iter()
.copied()
.find(|l| negotiable(l.as_tag()).map(|id| id.to_string()).as_deref() == Some(&winner))
}
}
pub(crate) type NegotiableId = fluent_langneg::LanguageIdentifier;
pub(crate) fn negotiable(tag: &str) -> Option<NegotiableId> {
NegotiableId::from_str(&normalise_locale_string(tag)).ok()
}
static UI_LANGUAGE: OnceLock<Language> = OnceLock::new();
pub fn init(language: Option<Language>) {
#[cfg(windows)]
console::enable_utf8();
if let Some(lang) = language {
let _ = UI_LANGUAGE.set(lang);
}
}
#[must_use]
pub fn current() -> Language {
*UI_LANGUAGE.get_or_init(detect_system_language)
}
#[must_use]
pub fn t(msg: Message) -> &'static str {
msg.text(current())
}
#[must_use]
pub fn detect_system_language() -> Language {
sys_locale::get_locale()
.as_deref()
.and_then(Language::from_tag)
.unwrap_or(Language::En)
}
#[must_use]
pub fn primary_subtag(locale: &str) -> String {
locale
.split(['-', '_', '.'])
.next()
.unwrap_or(locale)
.to_lowercase()
}
pub(crate) fn normalise_locale_string(raw: &str) -> String {
let trimmed = raw.trim();
let without_modifier = trimmed.split('@').next().unwrap_or(trimmed);
let without_encoding = without_modifier
.split('.')
.next()
.unwrap_or(without_modifier);
without_encoding.replace('_', "-")
}
#[cfg(windows)]
mod console {
const DEFAULT_CP_UTF8: u32 = 65001;
fn console_code_page() -> u32 {
crate::config::tuning_u32_in_range(
"i18n.windows_console_code_page",
DEFAULT_CP_UTF8,
1,
65_535,
)
}
#[link(name = "kernel32")]
extern "system" {
fn SetConsoleOutputCP(code_page: u32) -> i32;
fn SetConsoleCP(code_page: u32) -> i32;
}
pub(super) fn enable_utf8() {
let code_page = console_code_page();
unsafe {
SetConsoleOutputCP(code_page);
SetConsoleCP(code_page);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_catalogue_names_every_accepted_output_format() {
use clap::ValueEnum;
let spellings: Vec<String> = crate::cli::FormatArg::value_variants()
.iter()
.map(|variant| {
variant
.to_possible_value()
.expect("no FormatArg variant is skipped")
.get_name()
.to_string()
})
.collect();
assert!(
spellings.len() >= 2,
"only {} spelling(s) were derived, so this test proved nothing",
spellings.len()
);
let mut checked = 0_usize;
for language in Language::compiled() {
let rendered = Message::ConfigInvalidFormat.text(*language);
for spelling in &spellings {
assert!(
rendered.contains(spelling.as_str()),
"the {} catalogue renders the invalid-format message as \
{rendered:?}, which never mentions the accepted spelling \
{spelling:?}",
language.as_tag()
);
}
checked += 1;
}
assert!(
checked >= 2,
"only {checked} catalogue(s) were compared, so this test proved nothing"
);
}
#[test]
fn every_message_has_english_and_brazilian_portuguese() {
for msg in Message::all() {
let english = msg.text(Language::En);
let portuguese = msg.text(Language::PtBr);
assert!(!english.is_empty(), "empty English text for {msg:?}");
assert!(
!portuguese.is_empty(),
"empty Brazilian Portuguese text for {msg:?}"
);
}
}
#[test]
fn portuguese_translates_the_bulk_of_the_catalogue() {
let identical = Message::all()
.iter()
.filter(|m| m.text(Language::En) == m.text(Language::PtBr))
.count();
let max_identical =
crate::config::tuning_usize_in_range("i18n.max_untranslated_messages", 2, 0, 64);
assert!(
identical <= max_identical,
"{identical} messages are byte-identical between en and pt-BR"
);
}
#[test]
fn every_compiled_locale_answers_for_every_message() {
for lang in Language::compiled() {
for msg in Message::all() {
assert!(
!msg.text(*lang).is_empty(),
"empty text for {msg:?} in {}",
lang.as_tag()
);
}
}
}
#[test]
fn from_tag_accepts_posix_and_bcp47_shapes() {
assert_eq!(Language::from_tag("pt_BR.UTF-8"), Some(Language::PtBr));
assert_eq!(Language::from_tag("PT-br"), Some(Language::PtBr));
assert_eq!(Language::from_tag("pt"), Some(Language::PtBr));
assert_eq!(Language::from_tag("en-US"), Some(Language::En));
assert_eq!(Language::from_tag(" en "), Some(Language::En));
}
#[test]
fn from_tag_rejects_locale_absent_from_this_build() {
assert_eq!(Language::from_tag("xx"), None);
}
#[test]
fn from_tag_rejects_malformed_input() {
assert_eq!(Language::from_tag("not a tag at all"), None);
assert_eq!(Language::from_tag(""), None);
}
#[test]
fn primary_subtag_reduces_region_and_encoding() {
assert_eq!(primary_subtag("pt-BR"), "pt");
assert_eq!(primary_subtag("pt_BR.UTF-8"), "pt");
assert_eq!(primary_subtag("EN"), "en");
assert_eq!(primary_subtag("es"), "es");
assert_eq!(primary_subtag("zh-Hant-TW"), "zh");
}
#[test]
fn normalise_locale_string_drops_encoding_and_modifier() {
assert_eq!(normalise_locale_string(" pt_BR.UTF-8 "), "pt-BR");
assert_eq!(normalise_locale_string("de_DE@euro"), "de-DE");
}
#[test]
fn compiled_tags_always_lists_the_baseline() {
let tags = Language::compiled_tags();
assert!(tags.contains("en"), "missing en in {tags}");
assert!(tags.contains("pt-BR"), "missing pt-BR in {tags}");
}
#[test]
fn detect_system_language_never_panics() {
let resolved = detect_system_language();
assert!(Language::compiled().contains(&resolved));
}
}