Skip to main content

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}