tawasuyu

tawasuyu/mirada

Sovereign tiling Wayland compositor + window manager in Rust, on Llimphi.

Rust 50Markdown 30TOML 211282 Definiciones

⎇ 3dc85ebdcdd5fda0a1be0db5f8fbce5859266f2e shared/rimay-localize/src/lib.rs Rust · 333 líneas · 11.7 KB Historia Quién tocó qué crudo

feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro) 3dc85ebdc · Sergio ·

1//! `rimay-localize` — i18n del escritorio gioser sobre Fluent.
2//!
3//! Disciplina (PLAN.md §6.3):
4//!
5//! - Los `*-core` agnósticos **no contienen strings de UI**. Emiten
6//!   identificadores (`MsgId`) que las apps Llimphi resuelven a texto
7//!   localizado aquí, al renderizar.
8//! - Un único catálogo `.ftl` por idioma vive en `locales/{lang}.ftl`,
9//!   embebido en el binario vía [`include_str!`].
10//! - El locale activo es un singleton de proceso. Cambiarlo recarga el
11//!   bundle pero **no** retransla automáticamente la vista — las apps
12//!   Llimphi vuelven a llamar a [`t`] en el próximo `view()`.
13//!
14//! ## API mínima
15//!
16//! ```no_run
17//! use rimay_localize as l10n;
18//!
19//! // Una vez al inicio de la app: detecta `LANG`/sistema, fallback es-PE.
20//! l10n::init();
21//!
22//! // En cualquier `view()`:
23//! let label = l10n::t("save");
24//!
25//! // Con argumentos posicionales tipo Fluent `{ $name }`:
26//! let greet = l10n::t_args("welcome-user", &[("name", "Sergio".into())]);
27//! ```
28//!
29//! ## Por qué Fluent (y no gettext / embeddings)
30//!
31//! - **Fluent** trae plurales/género/contextos declarativos en el `.ftl`,
32//!   sin macros de código. Esencial para idiomas aglutinantes (quechua)
33//!   donde la pluralización no es la binaria one/other del inglés.
34//! - **Embeddings** son la herramienta correcta para *búsqueda semántica*
35//!   (command palette, intent → acción) — ver [`rimay-verbo`]. **No** para
36//!   strings deterministas de UI.
37//!
38//! ## Alcance fuera de wawa
39//!
40//! Este crate requiere `std` y `alloc` (Fluent tira de ambos). El kernel
41//! `wawa` es `no_std` y no se localiza: emite **códigos** de error que
42//! las apps WASM por encima traducen consultando este catálogo.
43
44#![forbid(unsafe_code)]
45
46use std::borrow::Cow;
47use std::collections::HashMap;
48
49use fluent_bundle::concurrent::FluentBundle;
50use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
51use once_cell::sync::Lazy;
52use parking_lot::RwLock;
53use thiserror::Error;
54use tracing::warn;
55use unic_langid::LanguageIdentifier;
56
57// =====================================================================
58// Catálogos embebidos
59// =====================================================================
60
61/// Lista de catálogos compilada al binario. Para añadir un idioma:
62/// 1. Crear `locales/{lang}.ftl`.
63/// 2. Añadir la tupla `("{lang}", include_str!(...))` aquí.
64///
65/// **Orden = prioridad declarada del proyecto**: español primero (es el
66/// fallback y la lengua de trabajo), quechua segundo (lengua de la
67/// arquitectura del monorepo), inglés tercero (uso técnico). Cambios
68/// futuros conservan este orden por convención.
69const CATALOGS: &[(&str, &str)] = &[
70    ("es-PE", include_str!("../locales/es.ftl")),
71    ("qu-PE", include_str!("../locales/qu.ftl")),
72    ("en-US", include_str!("../locales/en.ftl")),
73];
74
75/// Locale por defecto cuando la detección del sistema falla o pide algo
76/// que no tenemos catalogado.
77pub const FALLBACK_LOCALE: &str = "es-PE";
78
79// =====================================================================
80// Errores
81// =====================================================================
82
83#[derive(Debug, Error)]
84pub enum LocalizeError {
85    #[error("locale '{0}' no está catalogado")]
86    UnknownLocale(String),
87    #[error("parseando catálogo de '{0}': {1}")]
88    CatalogParse(String, String),
89    #[error("identificador de locale inválido '{0}': {1}")]
90    InvalidLangId(String, String),
91}
92
93// =====================================================================
94// Estado global
95// =====================================================================
96
97struct State {
98    /// Locale activo (clave de [`CATALOGS`]).
99    active: String,
100    /// Un bundle por catálogo embebido. Se construyen perezosamente la
101    /// primera vez que un locale entra en uso y se cachean.
102    bundles: HashMap<String, FluentBundle<FluentResource>>,
103}
104
105impl State {
106    fn new() -> Self {
107        Self {
108            active: FALLBACK_LOCALE.to_string(),
109            bundles: HashMap::new(),
110        }
111    }
112
113    fn ensure_bundle(&mut self, locale: &str) -> Result<(), LocalizeError> {
114        if self.bundles.contains_key(locale) {
115            return Ok(());
116        }
117        let src = CATALOGS
118            .iter()
119            .find(|(l, _)| *l == locale)
120            .map(|(_, s)| *s)
121            .ok_or_else(|| LocalizeError::UnknownLocale(locale.to_string()))?;
122        let langid: LanguageIdentifier = locale
123            .parse()
124            .map_err(|e: unic_langid::LanguageIdentifierError| {
125                LocalizeError::InvalidLangId(locale.to_string(), e.to_string())
126            })?;
127        let res = FluentResource::try_new(src.to_string()).map_err(|(_, errs)| {
128            LocalizeError::CatalogParse(
129                locale.to_string(),
130                errs.into_iter()
131                    .map(|e| e.to_string())
132                    .collect::<Vec<_>>()
133                    .join("; "),
134            )
135        })?;
136        let mut bundle = FluentBundle::new_concurrent(vec![langid]);
137        // Fluent inserta caracteres bidi U+2068/U+2069 alrededor de los
138        // placeables. En una UI de escritorio que no soporta BIDI complejo
139        // (Llimphi no lo hace todavía) se ven como ◌. Los desactivamos:
140        // los catálogos no mezclan RTL/LTR por ahora.
141        bundle.set_use_isolating(false);
142        if let Err(errs) = bundle.add_resource(res) {
143            warn!(target: "rimay-localize", ?errs, "errores al añadir recurso a bundle '{locale}'");
144        }
145        self.bundles.insert(locale.to_string(), bundle);
146        Ok(())
147    }
148}
149
150static STATE: Lazy<RwLock<State>> = Lazy::new(|| RwLock::new(State::new()));
151
152// =====================================================================
153// API pública
154// =====================================================================
155
156/// Inicializa el localizador detectando el locale del sistema (env
157/// `LANG`/`LC_ALL` vía [`sys_locale`]) y eligiendo el más cercano de los
158/// catálogos disponibles. Si no hay match, cae en [`FALLBACK_LOCALE`].
159///
160/// Idempotente — invocaciones sucesivas resetean el locale activo según
161/// el sistema actual. Si la app quiere fijar el locale a mano, usar
162/// [`set_locale`] después.
163pub fn init() {
164    let detected = sys_locale::get_locale().unwrap_or_else(|| FALLBACK_LOCALE.to_string());
165    let chosen = best_match(&detected).unwrap_or_else(|| FALLBACK_LOCALE.to_string());
166    let _ = set_locale(&chosen);
167}
168
169/// Cambia el locale activo. Compila el catálogo correspondiente si aún
170/// no estaba cargado.
171pub fn set_locale(locale: &str) -> Result<(), LocalizeError> {
172    let mut state = STATE.write();
173    state.ensure_bundle(locale)?;
174    state.active = locale.to_string();
175    Ok(())
176}
177
178/// Devuelve el locale activo (clave de [`CATALOGS`]).
179pub fn current_locale() -> String {
180    STATE.read().active.clone()
181}
182
183/// Lista de locales disponibles (claves de [`CATALOGS`]).
184pub fn available_locales() -> Vec<&'static str> {
185    CATALOGS.iter().map(|(l, _)| *l).collect()
186}
187
188/// Resuelve un mensaje sin argumentos. Si el ID no existe en el catálogo
189/// activo, devuelve el propio ID — facilita ver qué falta traducir sin
190/// crashear la UI.
191pub fn t(id: &str) -> String {
192    resolve(id, None)
193}
194
195/// Resuelve un mensaje con argumentos posicionales tipo Fluent
196/// (`{ $name }`). Los valores se convierten a [`FluentValue`] vía la
197/// impl `From<Cow<str>>` — números pásalos pre-formateados como string
198/// si querés controlar la presentación.
199pub fn t_args(id: &str, args: &[(&str, Cow<'_, str>)]) -> String {
200    let mut fa = FluentArgs::new();
201    for (k, v) in args {
202        fa.set(*k, FluentValue::from(v.clone().into_owned()));
203    }
204    resolve(id, Some(&fa))
205}
206
207// =====================================================================
208// Internos
209// =====================================================================
210
211fn resolve(id: &str, args: Option<&FluentArgs>) -> String {
212    // Auto-init lazy: si `t()` se llama sin `init()` previo (típico desde
213    // librerías como `cosmos-modules` que no ven el `main`), cargamos el
214    // fallback en ese momento. Es un costo amortizado de una sola vez.
215    if STATE.read().bundles.is_empty() {
216        let mut w = STATE.write();
217        if w.bundles.is_empty() {
218            let _ = w.ensure_bundle(FALLBACK_LOCALE);
219            w.active = FALLBACK_LOCALE.to_string();
220        }
221    }
222    let state = STATE.read();
223    let bundle = match state.bundles.get(&state.active) {
224        Some(b) => b,
225        None => return id.to_string(),
226    };
227    let Some(msg) = bundle.get_message(id) else {
228        return id.to_string();
229    };
230    let Some(pattern) = msg.value() else {
231        return id.to_string();
232    };
233    let mut errors = vec![];
234    let s = bundle.format_pattern(pattern, args, &mut errors);
235    if !errors.is_empty() {
236        warn!(target: "rimay-localize", ?errors, "errores formateando '{id}' en locale '{}'", state.active);
237    }
238    s.into_owned()
239}
240
241/// Mejor match entre un locale solicitado y los catalogados.
242///
243/// 1. Match exacto (`qu-PE` → `qu-PE`).
244/// 2. Match por lengua base ignorando región (`es-AR` → `es-PE`,
245///    `qu-BO` → `qu-PE`).
246/// 3. Sin match → `None`.
247fn best_match(requested: &str) -> Option<String> {
248    if CATALOGS.iter().any(|(l, _)| *l == requested) {
249        return Some(requested.to_string());
250    }
251    let base = requested.split(['-', '_']).next()?;
252    CATALOGS
253        .iter()
254        .find(|(l, _)| l.split('-').next() == Some(base))
255        .map(|(l, _)| l.to_string())
256}
257
258// =====================================================================
259// Tests
260// =====================================================================
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use std::sync::Mutex;
266
267    // Los tests comparten estado global → serialización manual.
268    static SERIAL: Mutex<()> = Mutex::new(());
269
270    #[test]
271    fn fallback_locale_resolves() {
272        let _g = SERIAL.lock().unwrap();
273        set_locale("es-PE").unwrap();
274        let s = t("save");
275        assert_eq!(s, "Guardar");
276    }
277
278    #[test]
279    fn switches_to_english() {
280        let _g = SERIAL.lock().unwrap();
281        set_locale("en-US").unwrap();
282        assert_eq!(t("save"), "Save");
283        assert_eq!(t("cancel"), "Cancel");
284    }
285
286    #[test]
287    fn quechua_loads() {
288        let _g = SERIAL.lock().unwrap();
289        set_locale("qu-PE").unwrap();
290        // Solo verificamos que devuelva *algo distinto* del id, no la
291        // traducción literal — para no acoplar el test al fraseo exacto
292        // del catálogo (que el revisor de quechua va a ajustar).
293        let s = t("save");
294        assert_ne!(s, "save", "qu-PE no resolvió 'save'");
295    }
296
297    #[test]
298    fn unknown_id_returns_id_as_degradation() {
299        let _g = SERIAL.lock().unwrap();
300        set_locale("es-PE").unwrap();
301        assert_eq!(t("__id_que_no_existe__"), "__id_que_no_existe__");
302    }
303
304    #[test]
305    fn args_interpolate() {
306        let _g = SERIAL.lock().unwrap();
307        set_locale("es-PE").unwrap();
308        let s = t_args("welcome-user", &[("name", "Sergio".into())]);
309        assert!(s.contains("Sergio"), "no interpoló name: {s}");
310    }
311
312    #[test]
313    fn best_match_region_fallback() {
314        assert_eq!(best_match("es-AR"), Some("es-PE".to_string()));
315        assert_eq!(best_match("en-GB"), Some("en-US".to_string()));
316        assert_eq!(best_match("qu-BO"), Some("qu-PE".to_string()));
317        assert_eq!(best_match("ja-JP"), None);
318    }
319
320    #[test]
321    fn unknown_locale_errors() {
322        let err = set_locale("xx-YY").unwrap_err();
323        assert!(matches!(err, LocalizeError::UnknownLocale(_)));
324    }
325
326    #[test]
327    fn available_locales_lists_all() {
328        let v = available_locales();
329        assert!(v.contains(&"es-PE"));
330        assert!(v.contains(&"en-US"));
331        assert!(v.contains(&"qu-PE"));
332    }
333}