🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
//! # libluna-i18n
//!
//! Lekki system wielojęzyczny dla libLuny — bez gettext i bez zależności zewnętrznych.
//!
//! Model jest celowo prosty, w duchu gettext:
//!
//! - **kluczem jest angielski napis** (np. `"Appearance"`), więc brak tłumaczenia
//! degraduje się łagodnie do angielskiego;
//! - każdy język to **osobny plik** `i18n/<kod>.lang` (format `klucz = tłumaczenie`),
//! wbudowany w binarkę przez `include_str!` — łatwo dodawać i edytować;
//! - dodatkowo języki można **doładować w czasie działania** z katalogów
//! (`/usr/share/luna/i18n`, `~/.local/share/luna/i18n`, `~/.config/luna/i18n`).
//!
//! ```no_run
//! use libluna_i18n::{tr, Locale};
//! libluna_i18n::set_locale(Locale::new("pl"));
//! assert_eq!(tr("Appearance"), "Wygląd");
//! ```
//!
//! Podstawianie argumentów: `tr_args("Hello, {name}", &[("name", "Ania")])`.
//! Każdy katalog może zdefiniować `__name__` — natywną nazwę języka do UI.
use std::collections::{BTreeMap, HashMap};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{OnceLock, RwLock};
/// Klucz z nazwą języka w danym katalogu (np. `Polski`).
const NAME_KEY: &str = "__name__";
/// Znacznik języka, np. `pl`, `pl_PL`, `en`, `de`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Locale(String);
impl Locale {
/// Tworzy język z tagu (np. `pl_PL.UTF-8` → `pl`).
pub fn new(tag: impl AsRef<str>) -> Self {
let raw = tag.as_ref();
// Utnij kodowanie (`.UTF-8`) i modyfikator (`@euro`).
let base = raw.split(['.', '@']).next().unwrap_or(raw);
let language = base.split(['_', '-']).next().unwrap_or(base);
Self(language.to_ascii_lowercase())
}
/// Wykrywa język ze środowiska (`LC_ALL`, `LC_MESSAGES`, `LANG`).
pub fn from_env() -> Self {
for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Ok(value) = std::env::var(key) {
if !value.is_empty() && value != "C" && value != "POSIX" {
return Locale::new(value);
}
}
}
Locale::new("en")
}
/// Kod języka (np. `pl`).
pub fn language(&self) -> &str {
&self.0
}
}
impl Default for Locale {
fn default() -> Self {
Locale::new("en")
}
}
impl From<&str> for Locale {
fn from(value: &str) -> Self {
Locale::new(value)
}
}
impl From<String> for Locale {
fn from(value: String) -> Self {
Locale::new(value)
}
}
/// Katalog tłumaczeń jednego języka.
#[derive(Debug, Clone, Default)]
pub struct Catalog {
entries: HashMap<String, String>,
}
impl Catalog {
pub fn new() -> Self {
Self::default()
}
/// Buduje katalog z tekstu (format `klucz = tłumaczenie`).
pub fn parse(src: &str) -> Self {
let mut catalog = Self::new();
catalog.parse_into(src);
catalog
}
/// Dodaje/aktualizuje wpisy z tekstu (pozwala nakładać pliki na siebie).
pub fn parse_into(&mut self, src: &str) {
for line in src.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
self.entries
.insert(key.trim().to_owned(), value.trim().to_owned());
}
}
}
/// Tłumaczenie dla klucza, jeśli istnieje.
pub fn get(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(String::as_str)
}
/// Natywna nazwa języka z `__name__` (jeśli zdefiniowana).
pub fn name(&self) -> Option<&str> {
self.get(NAME_KEY)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
/// Zbiór katalogów + bieżący język + język zapasowy.
#[derive(Debug, Clone)]
pub struct Translations {
locale: Locale,
catalogs: BTreeMap<String, Catalog>,
fallback: String,
}
impl Translations {
/// Tworzy pusty zbiór z językiem zapasowym (domyślnie `en`).
pub fn new(locale: Locale) -> Self {
Self {
locale,
catalogs: BTreeMap::new(),
fallback: "en".to_owned(),
}
}
/// Dodaje/nałada katalog języka z tekstu.
pub fn add_source(&mut self, language: &str, src: &str) -> &mut Self {
self.catalogs
.entry(language.to_ascii_lowercase())
.or_default()
.parse_into(src);
self
}
/// Zestaw wbudowanych języków libLuny (pliki `i18n/*.lang` wbudowane w binarkę).
pub fn builtin() -> Self {
let mut t = Translations::new(Locale::from_env());
t.add_source("en", include_str!("../i18n/en.lang"));
t.add_source("pl", include_str!("../i18n/pl.lang"));
t.add_source("de", include_str!("../i18n/de.lang"));
t
}
pub fn locale(&self) -> &Locale {
&self.locale
}
/// Ustawia bieżący język.
pub fn set_locale(&mut self, locale: Locale) {
self.locale = locale;
}
/// Krótki kod bieżącego języka.
pub fn language(&self) -> &str {
self.locale.language()
}
/// Dostępne kody języków (posortowane).
pub fn languages(&self) -> Vec<String> {
self.catalogs.keys().cloned().collect()
}
/// Natywna nazwa języka z jego katalogu (np. `Polski`).
pub fn language_name(&self, language: &str) -> Option<&str> {
self.catalogs.get(language).and_then(Catalog::name)
}
/// Tłumaczenie klucza; przy braku wpisu zwraca sam klucz (angielski).
pub fn translate<'a>(&'a self, key: &'a str) -> &'a str {
if let Some(catalog) = self.catalogs.get(self.locale.language()) {
if let Some(value) = catalog.get(key) {
return value;
}
}
if let Some(catalog) = self.catalogs.get(&self.fallback) {
if let Some(value) = catalog.get(key) {
return value;
}
}
key
}
/// Tłumaczenie z podstawieniem `{argumentów}`.
pub fn translate_args(&self, key: &str, args: &[(&str, &str)]) -> String {
let mut out = self.translate(key).to_owned();
for (name, value) in args {
out = out.replace(&format!("{{{name}}}"), value);
}
out
}
/// Doładowuje katalog z pojedynczego pliku `*.lang` (kod języka = nazwa pliku).
pub fn load_file(&mut self, path: &Path) -> io::Result<()> {
let Some(code) = path.file_stem().and_then(|s| s.to_str()) else {
return Ok(());
};
let src = std::fs::read_to_string(path)?;
self.add_source(code, &src);
Ok(())
}
/// Doładowuje wszystkie pliki `*.lang` z katalogu; zwraca liczbę wczytanych.
pub fn load_dir(&mut self, dir: &Path) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut count = 0;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("lang")
&& self.load_file(&path).is_ok()
{
count += 1;
}
}
count
}
}
impl Default for Translations {
fn default() -> Self {
Translations::builtin()
}
}
/// Standardowe katalogi wyszukiwania języków doładowywanych w runtime.
pub fn catalog_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
dirs.push(PathBuf::from("/usr/share/luna/i18n"));
if let Some(data) = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
{
dirs.push(data.join("luna/i18n"));
}
if let Some(config) = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
{
dirs.push(config.join("luna/i18n"));
}
dirs
}
/// Globalna instancja tłumaczeń.
fn global() -> &'static RwLock<Translations> {
static GLOBAL: OnceLock<RwLock<Translations>> = OnceLock::new();
GLOBAL.get_or_init(|| RwLock::new(Translations::builtin()))
}
/// Ustawia globalny język.
pub fn set_locale(locale: impl Into<Locale>) {
if let Ok(mut guard) = global().write() {
guard.set_locale(locale.into());
}
}
/// Bieżący globalny język.
pub fn locale() -> Locale {
global()
.read()
.map(|g| g.locale().clone())
.unwrap_or_default()
}
/// Doładowuje języki ze standardowych katalogów (nadpisują wbudowane).
/// Zwraca liczbę wczytanych plików.
pub fn load_catalogs_from_dirs() -> usize {
let dirs = catalog_dirs();
match global().write() {
Ok(mut guard) => dirs.iter().map(|d| guard.load_dir(d)).sum(),
Err(_) => 0,
}
}
/// Dostępne kody języków (globalnie).
pub fn languages() -> Vec<String> {
global().read().map(|g| g.languages()).unwrap_or_default()
}
/// Natywna nazwa języka (globalnie).
pub fn language_name(language: &str) -> Option<String> {
global()
.read()
.ok()
.and_then(|g| g.language_name(language).map(str::to_owned))
}
/// Przetłumaczony napis (globalnie).
pub fn tr(key: &str) -> String {
global()
.read()
.map(|g| g.translate(key).to_owned())
.unwrap_or_else(|_| key.to_owned())
}
/// Przetłumaczony napis z podstawieniem argumentów (globalnie).
pub fn tr_args(key: &str, args: &[(&str, &str)]) -> String {
global()
.read()
.map(|g| g.translate_args(key, args))
.unwrap_or_else(|_| key.to_owned())
}
/// Skrót do [`tr`] / [`tr_args`].
///
/// ```ignore
/// let s = t!("Appearance");
/// let s = t!("Witaj, {name}", name = "Ania");
/// ```
#[macro_export]
macro_rules! t {
($key:expr) => {
$crate::tr($key)
};
($key:expr, $($k:ident = $v:expr),+ $(,)?) => {
$crate::tr_args($key, &[$( (stringify!($k), $v) ),+])
};
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn locale_normalization() {
assert_eq!(Locale::new("pl_PL.UTF-8").language(), "pl");
assert_eq!(Locale::new("de-DE").language(), "de");
assert_eq!(Locale::new("en_US").language(), "en");
assert_eq!(Locale::new("C").language(), "c");
}
#[test]
fn catalog_parses_and_ignores_comments() {
let c = Catalog::parse("# komentarz\nA = B\n\nC = D = E\n");
assert_eq!(c.get("A"), Some("B"));
// Dzielimy tylko po pierwszym `=`.
assert_eq!(c.get("C"), Some("D = E"));
}
#[test]
fn builtin_translates_and_falls_back() {
let mut t = Translations::new(Locale::new("pl"));
t.add_source("pl", include_str!("../i18n/pl.lang"));
assert_eq!(t.translate("Appearance"), "Wygląd");
assert_eq!(t.translate("Nieznany klucz"), "Nieznany klucz");
}
#[test]
fn switching_locale_changes_output() {
let mut t = Translations::new(Locale::new("en"));
t.add_source("pl", include_str!("../i18n/pl.lang"));
t.add_source("de", include_str!("../i18n/de.lang"));
t.set_locale(Locale::new("de"));
assert_eq!(t.translate("Dark"), "Dunkel");
t.set_locale(Locale::new("pl"));
assert_eq!(t.translate("Dark"), "Ciemny");
}
#[test]
fn language_names_and_listing() {
let t = Translations::builtin();
assert_eq!(t.language_name("pl"), Some("Polski"));
assert_eq!(t.language_name("de"), Some("Deutsch"));
assert_eq!(t.language_name("en"), Some("English"));
assert!(t.languages().contains(&"pl".to_owned()));
}
#[test]
fn args_substitution() {
let t = Translations::new(Locale::new("en"));
assert_eq!(
t.translate_args("Hi, {name}!", &[("name", "Ada")]),
"Hi, Ada!"
);
}
#[test]
fn load_file_overrides_and_adds() {
let dir = std::env::temp_dir().join("libluna-i18n-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("xx.lang");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "__name__ = Testish").unwrap();
writeln!(f, "Appearance = Xyglad").unwrap();
drop(f);
let mut t = Translations::builtin();
t.load_file(&path).unwrap();
t.set_locale(Locale::new("xx"));
assert_eq!(t.translate("Appearance"), "Xyglad");
assert_eq!(t.language_name("xx"), Some("Testish"));
// Klucz nieprzetłumaczony w `xx` spada do bazy `en`.
assert_eq!(t.translate("System"), "System");
let _ = std::fs::remove_file(&path);
}
}