bext_plugin_api/i18n.rs
1//! I18n capability trait. See `plan/ecosystem/02-capabilities.md` §I18n.
2//!
3//! An `I18nPlugin` turns a translation key plus a locale plus a bag of
4//! variables into a localized string. The trait is shaped so that the
5//! same surface covers three very different backends: static JSON
6//! files (`@bext/i18n-static`), Mozilla Fluent (`@bext/i18n-fluent`),
7//! and ICU MessageFormat (`@bext/i18n-icu`). A project swaps between
8//! them in `bext.config.toml` without touching code.
9//!
10//! # Design notes
11//!
12//! - **Sync, object-safe.** Like every other capability in this crate,
13//! `I18nPlugin` is `Send + Sync`, has no generics on the trait, and
14//! is callable from WASM, QuickJS, and native host code alike.
15//!
16//! - **Owned `String` return, not `Cow<'_, str>`.** The plan sketch
17//! uses `Cow<'_, str>`, which would let a backend return a borrowed
18//! slice of a statically-interned key when no interpolation
19//! happened. That borrow would have to live as long as the backend,
20//! which forces either a lifetime on the trait or an internal
21//! self-referential mess across the FFI boundary. Owning the string
22//! costs one allocation per call and makes every other concern
23//! (WASM guests, host-fn marshalling, async bridging, handle
24//! storage) trivial.
25//!
26//! - **Missing keys never error.** `translate` returns
27//! `Result<String, I18nError>`, but the `Ok` value is a
28//! best-effort rendering — if the key is unknown, the backend
29//! returns `Ok("{key}")` (or whatever its configured missing-key
30//! policy is). A missing translation should never take down a UI.
31//! Errors are reserved for genuine backend failures: a locale that
32//! was promised in `supported_locales()` but failed to load, a
33//! Fluent syntax error in a bundle, an ICU formatter that could
34//! not format a variable. The caller branches on `Err` to decide
35//! whether to retry, fall back to a hardcoded English string, or
36//! surface a 500.
37//!
38//! - **`TransVars` is a flat `HashMap<String, I18nValue>`.** Passing
39//! `&HashMap<String, String>` would be ABI-flat but would force
40//! backends to re-parse numeric and date arguments from strings,
41//! which is exactly the kind of bug that ICU MessageFormat exists
42//! to prevent. Instead we carry a tagged enum of the small set of
43//! variable types every major i18n library supports: string, i64,
44//! f64, bool. Plurals are driven by `i64` and `f64` naturally;
45//! `bool` covers Fluent's `male`/`female`/`other` selector shape.
46//!
47//! - **`supported_locales` returns owned strings.** A borrowed
48//! `&[String]` would be fine today but would bind the slice to the
49//! lifetime of the plugin, which crosses badly through dyn-trait
50//! host-fn dispatch. Owned `Vec<String>` is one allocation on what
51//! is an infrequently-called, boot-time query.
52//!
53//! - **`fallback_locale` is a single-locale answer, not a chain.**
54//! BCP 47 negotiation (asking for `fr-CA` and getting `fr`) is the
55//! *caller*'s job — specifically, it belongs in the
56//! `I18nPlugin::negotiate` helper, which takes a requested locale
57//! and walks backward to the broadest tag the plugin supports,
58//! finally falling back to `fallback_locale`. Backends do not
59//! need to implement negotiation themselves; they only declare
60//! what they support.
61
62use serde::{Deserialize, Serialize};
63use std::collections::HashMap;
64
65/// A variable passed to `translate` for interpolation.
66///
67/// The variants map 1:1 onto the types every major i18n library
68/// understands without loss:
69///
70/// - `String` — the common case, arbitrary text.
71/// - `I64` — counts, integer ids. Drives plural selection in both
72/// Fluent and ICU MessageFormat.
73/// - `F64` — ratios, currency amounts. Backends that format numbers
74/// (ICU) use this as the input; simpler backends stringify.
75/// - `Bool` — binary selectors. Fluent uses booleans to drive
76/// `{ $isFormal -> [true] ... *[false] ... }` patterns.
77///
78/// Dates are deliberately omitted: a date in a translation is almost
79/// always a pre-formatted string (which goes through `String`) or a
80/// Unix timestamp (which goes through `I64`). Exposing a dedicated
81/// `Date` variant would pull in chrono or time as a crate dependency,
82/// which this crate does not take.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case", tag = "type", content = "value")]
85pub enum I18nValue {
86 String(String),
87 I64(i64),
88 F64(f64),
89 Bool(bool),
90}
91
92impl From<&str> for I18nValue {
93 fn from(s: &str) -> Self {
94 I18nValue::String(s.to_owned())
95 }
96}
97impl From<String> for I18nValue {
98 fn from(s: String) -> Self {
99 I18nValue::String(s)
100 }
101}
102impl From<i64> for I18nValue {
103 fn from(n: i64) -> Self {
104 I18nValue::I64(n)
105 }
106}
107impl From<i32> for I18nValue {
108 fn from(n: i32) -> Self {
109 I18nValue::I64(n as i64)
110 }
111}
112impl From<u32> for I18nValue {
113 fn from(n: u32) -> Self {
114 I18nValue::I64(n as i64)
115 }
116}
117impl From<f64> for I18nValue {
118 fn from(n: f64) -> Self {
119 I18nValue::F64(n)
120 }
121}
122impl From<bool> for I18nValue {
123 fn from(b: bool) -> Self {
124 I18nValue::Bool(b)
125 }
126}
127
128/// A bag of variables passed to `translate`.
129///
130/// The type alias exists so callers can write `TransVars::new()` and
131/// so future evolutions of the type (say, to a small-vec-backed map)
132/// do not ripple through every call site.
133pub type TransVars = HashMap<String, I18nValue>;
134
135/// Why a translate call failed. `Ok(rendered)` is the overwhelming
136/// majority path — every method here is reserved for a real backend
137/// failure the caller needs to decide about.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub enum I18nError {
140 /// The requested locale was promised by `supported_locales()` but
141 /// could not be loaded: a missing file, a parse error, a
142 /// corrupted bundle. Callers SHOULD retry with the fallback
143 /// locale or surface a generic English string.
144 LocaleUnavailable(String),
145 /// A bundle loaded fine but formatting the specific key failed:
146 /// an ICU MessageFormat placeholder that did not match the
147 /// variable bag, a Fluent function call error, a type mismatch
148 /// between a variable and the pattern that consumed it.
149 FormatError(String),
150 /// The backend is in a state where it cannot serve any
151 /// translations right now — an async reload is in flight, a
152 /// file watcher caught a broken state, a cache eviction storm.
153 /// Transient; callers MAY retry.
154 Unavailable(String),
155 /// The backend hit something it could not classify. Treat as
156 /// `Unavailable` unless you know better.
157 Unknown(String),
158}
159
160impl I18nError {
161 pub fn locale_unavailable(msg: impl Into<String>) -> Self {
162 I18nError::LocaleUnavailable(msg.into())
163 }
164 pub fn format_error(msg: impl Into<String>) -> Self {
165 I18nError::FormatError(msg.into())
166 }
167 pub fn unavailable(msg: impl Into<String>) -> Self {
168 I18nError::Unavailable(msg.into())
169 }
170 pub fn unknown(msg: impl Into<String>) -> Self {
171 I18nError::Unknown(msg.into())
172 }
173
174 pub fn message(&self) -> &str {
175 match self {
176 I18nError::LocaleUnavailable(m)
177 | I18nError::FormatError(m)
178 | I18nError::Unavailable(m)
179 | I18nError::Unknown(m) => m,
180 }
181 }
182}
183
184impl std::fmt::Display for I18nError {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 let tag = match self {
187 I18nError::LocaleUnavailable(_) => "locale-unavailable",
188 I18nError::FormatError(_) => "format-error",
189 I18nError::Unavailable(_) => "unavailable",
190 I18nError::Unknown(_) => "unknown",
191 };
192 write!(f, "i18n {}: {}", tag, self.message())
193 }
194}
195
196impl std::error::Error for I18nError {}
197
198/// A plugin that translates keys into localized strings.
199///
200/// Host functions `i18n.t(key, vars)` and `i18n.locales()` call
201/// through to the currently registered implementation. The host
202/// layer is responsible for negotiating the request's locale from
203/// headers / session / URL before calling `translate`.
204pub trait I18nPlugin: Send + Sync {
205 /// Unique identifier, e.g. `"static"`, `"fluent"`, `"icu"`.
206 fn name(&self) -> &str;
207
208 /// Translate `key` into `locale`, interpolating `vars`.
209 ///
210 /// Returns `Ok(rendered)` in the overwhelming majority of cases,
211 /// including when the key is unknown — backends render missing
212 /// keys as a safe marker (the key itself, or `"{key}"`) rather
213 /// than returning an error, because a missing translation must
214 /// never take down a UI.
215 ///
216 /// Returns `Err(I18nError)` only for genuine backend failures:
217 /// a locale that was promised but failed to load, a format
218 /// error, a backend in a transiently unavailable state.
219 fn translate(
220 &self,
221 key: &str,
222 locale: &str,
223 vars: &TransVars,
224 ) -> Result<String, I18nError>;
225
226 /// All locales this plugin can serve, as BCP 47 tags
227 /// (`"en"`, `"en-US"`, `"fr-CA"`, `"zh-Hant-TW"`). The order is
228 /// display order, not preference order; callers negotiate via
229 /// [`negotiate`](I18nPlugin::negotiate).
230 fn supported_locales(&self) -> Vec<String>;
231
232 /// The locale to use when the caller asks for one that is not
233 /// in `supported_locales()` and no partial match negotiated. A
234 /// BCP 47 tag; MUST appear in `supported_locales()`.
235 fn fallback_locale(&self) -> String;
236
237 /// Whether the plugin recognises the given locale tag exactly
238 /// (case-insensitive). The default implementation linearly
239 /// scans `supported_locales()`; backends that maintain a fast
240 /// lookup structure override this.
241 fn has_locale(&self, locale: &str) -> bool {
242 self.supported_locales()
243 .iter()
244 .any(|l| l.eq_ignore_ascii_case(locale))
245 }
246
247 /// Negotiate a requested locale down to a locale the plugin can
248 /// actually serve, per BCP 47 Lookup (RFC 4647 §3.4).
249 ///
250 /// The default implementation implements the standard walk:
251 /// given `"fr-CA"`, try `"fr-CA"`, then `"fr"`, then
252 /// `fallback_locale()`. Backends are free to override with a
253 /// richer matcher (Fluent, for example, has its own negotiation
254 /// that also considers script subtags).
255 fn negotiate(&self, requested: &str) -> String {
256 let mut candidate = requested.to_owned();
257 loop {
258 if self.has_locale(&candidate) {
259 return candidate;
260 }
261 match candidate.rfind('-') {
262 Some(idx) => candidate.truncate(idx),
263 None => return self.fallback_locale(),
264 }
265 }
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn i18n_value_from_impls() {
275 assert_eq!(I18nValue::from("x"), I18nValue::String("x".into()));
276 assert_eq!(I18nValue::from(String::from("x")), I18nValue::String("x".into()));
277 assert_eq!(I18nValue::from(5i64), I18nValue::I64(5));
278 assert_eq!(I18nValue::from(5i32), I18nValue::I64(5));
279 assert_eq!(I18nValue::from(5u32), I18nValue::I64(5));
280 assert_eq!(I18nValue::from(1.5f64), I18nValue::F64(1.5));
281 assert_eq!(I18nValue::from(true), I18nValue::Bool(true));
282 }
283
284 #[test]
285 fn i18n_error_helpers_and_display() {
286 let e = I18nError::locale_unavailable("en missing");
287 assert!(matches!(e, I18nError::LocaleUnavailable(_)));
288 assert_eq!(e.message(), "en missing");
289 assert!(e.to_string().contains("locale-unavailable"));
290 assert!(e.to_string().contains("en missing"));
291
292 let f = I18nError::format_error("bad placeholder");
293 assert!(matches!(f, I18nError::FormatError(_)));
294 }
295
296 /// Minimal plugin used to exercise the default `has_locale` and
297 /// `negotiate` implementations. Real plugins live in
298 /// `crates/bext-impls/`.
299 struct FakeI18n {
300 locales: Vec<String>,
301 fallback: String,
302 }
303
304 impl I18nPlugin for FakeI18n {
305 fn name(&self) -> &str {
306 "fake"
307 }
308 fn translate(
309 &self,
310 key: &str,
311 _locale: &str,
312 _vars: &TransVars,
313 ) -> Result<String, I18nError> {
314 Ok(format!("{{{}}}", key))
315 }
316 fn supported_locales(&self) -> Vec<String> {
317 self.locales.clone()
318 }
319 fn fallback_locale(&self) -> String {
320 self.fallback.clone()
321 }
322 }
323
324 #[test]
325 fn default_has_locale_is_case_insensitive() {
326 let p = FakeI18n {
327 locales: vec!["en".into(), "fr-CA".into(), "zh-Hant-TW".into()],
328 fallback: "en".into(),
329 };
330 assert!(p.has_locale("en"));
331 assert!(p.has_locale("EN"));
332 assert!(p.has_locale("fr-ca"));
333 assert!(p.has_locale("zh-hant-tw"));
334 assert!(!p.has_locale("de"));
335 }
336
337 #[test]
338 fn default_negotiate_walks_to_broader_tag() {
339 let p = FakeI18n {
340 locales: vec!["en".into(), "fr".into()],
341 fallback: "en".into(),
342 };
343 // Exact match wins.
344 assert_eq!(p.negotiate("en"), "en");
345 // fr-CA walks back to fr.
346 assert_eq!(p.negotiate("fr-CA"), "fr");
347 // zh-Hant-TW with no zh support falls through to fallback.
348 assert_eq!(p.negotiate("zh-Hant-TW"), "en");
349 // Completely unknown tag lands on fallback.
350 assert_eq!(p.negotiate("de"), "en");
351 }
352
353 #[test]
354 fn default_negotiate_exact_multi_tag_match() {
355 let p = FakeI18n {
356 locales: vec!["en".into(), "fr-CA".into(), "fr".into()],
357 fallback: "en".into(),
358 };
359 assert_eq!(p.negotiate("fr-CA"), "fr-CA");
360 assert_eq!(p.negotiate("fr-FR"), "fr");
361 }
362}