bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! I18n capability trait. See `plan/ecosystem/02-capabilities.md` §I18n.
//!
//! An `I18nPlugin` turns a translation key plus a locale plus a bag of
//! variables into a localized string. The trait is shaped so that the
//! same surface covers three very different backends: static JSON
//! files (`@bext/i18n-static`), Mozilla Fluent (`@bext/i18n-fluent`),
//! and ICU MessageFormat (`@bext/i18n-icu`). A project swaps between
//! them in `bext.config.toml` without touching code.
//!
//! # Design notes
//!
//! - **Sync, object-safe.** Like every other capability in this crate,
//!   `I18nPlugin` is `Send + Sync`, has no generics on the trait, and
//!   is callable from WASM, QuickJS, and native host code alike.
//!
//! - **Owned `String` return, not `Cow<'_, str>`.** The plan sketch
//!   uses `Cow<'_, str>`, which would let a backend return a borrowed
//!   slice of a statically-interned key when no interpolation
//!   happened. That borrow would have to live as long as the backend,
//!   which forces either a lifetime on the trait or an internal
//!   self-referential mess across the FFI boundary. Owning the string
//!   costs one allocation per call and makes every other concern
//!   (WASM guests, host-fn marshalling, async bridging, handle
//!   storage) trivial.
//!
//! - **Missing keys never error.** `translate` returns
//!   `Result<String, I18nError>`, but the `Ok` value is a
//!   best-effort rendering — if the key is unknown, the backend
//!   returns `Ok("{key}")` (or whatever its configured missing-key
//!   policy is). A missing translation should never take down a UI.
//!   Errors are reserved for genuine backend failures: a locale that
//!   was promised in `supported_locales()` but failed to load, a
//!   Fluent syntax error in a bundle, an ICU formatter that could
//!   not format a variable. The caller branches on `Err` to decide
//!   whether to retry, fall back to a hardcoded English string, or
//!   surface a 500.
//!
//! - **`TransVars` is a flat `HashMap<String, I18nValue>`.** Passing
//!   `&HashMap<String, String>` would be ABI-flat but would force
//!   backends to re-parse numeric and date arguments from strings,
//!   which is exactly the kind of bug that ICU MessageFormat exists
//!   to prevent. Instead we carry a tagged enum of the small set of
//!   variable types every major i18n library supports: string, i64,
//!   f64, bool. Plurals are driven by `i64` and `f64` naturally;
//!   `bool` covers Fluent's `male`/`female`/`other` selector shape.
//!
//! - **`supported_locales` returns owned strings.** A borrowed
//!   `&[String]` would be fine today but would bind the slice to the
//!   lifetime of the plugin, which crosses badly through dyn-trait
//!   host-fn dispatch. Owned `Vec<String>` is one allocation on what
//!   is an infrequently-called, boot-time query.
//!
//! - **`fallback_locale` is a single-locale answer, not a chain.**
//!   BCP 47 negotiation (asking for `fr-CA` and getting `fr`) is the
//!   *caller*'s job — specifically, it belongs in the
//!   `I18nPlugin::negotiate` helper, which takes a requested locale
//!   and walks backward to the broadest tag the plugin supports,
//!   finally falling back to `fallback_locale`. Backends do not
//!   need to implement negotiation themselves; they only declare
//!   what they support.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// A variable passed to `translate` for interpolation.
///
/// The variants map 1:1 onto the types every major i18n library
/// understands without loss:
///
/// - `String` — the common case, arbitrary text.
/// - `I64` — counts, integer ids. Drives plural selection in both
///   Fluent and ICU MessageFormat.
/// - `F64` — ratios, currency amounts. Backends that format numbers
///   (ICU) use this as the input; simpler backends stringify.
/// - `Bool` — binary selectors. Fluent uses booleans to drive
///   `{ $isFormal -> [true] ... *[false] ... }` patterns.
///
/// Dates are deliberately omitted: a date in a translation is almost
/// always a pre-formatted string (which goes through `String`) or a
/// Unix timestamp (which goes through `I64`). Exposing a dedicated
/// `Date` variant would pull in chrono or time as a crate dependency,
/// which this crate does not take.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type", content = "value")]
pub enum I18nValue {
    String(String),
    I64(i64),
    F64(f64),
    Bool(bool),
}

impl From<&str> for I18nValue {
    fn from(s: &str) -> Self {
        I18nValue::String(s.to_owned())
    }
}
impl From<String> for I18nValue {
    fn from(s: String) -> Self {
        I18nValue::String(s)
    }
}
impl From<i64> for I18nValue {
    fn from(n: i64) -> Self {
        I18nValue::I64(n)
    }
}
impl From<i32> for I18nValue {
    fn from(n: i32) -> Self {
        I18nValue::I64(n as i64)
    }
}
impl From<u32> for I18nValue {
    fn from(n: u32) -> Self {
        I18nValue::I64(n as i64)
    }
}
impl From<f64> for I18nValue {
    fn from(n: f64) -> Self {
        I18nValue::F64(n)
    }
}
impl From<bool> for I18nValue {
    fn from(b: bool) -> Self {
        I18nValue::Bool(b)
    }
}

/// A bag of variables passed to `translate`.
///
/// The type alias exists so callers can write `TransVars::new()` and
/// so future evolutions of the type (say, to a small-vec-backed map)
/// do not ripple through every call site.
pub type TransVars = HashMap<String, I18nValue>;

/// Why a translate call failed. `Ok(rendered)` is the overwhelming
/// majority path — every method here is reserved for a real backend
/// failure the caller needs to decide about.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum I18nError {
    /// The requested locale was promised by `supported_locales()` but
    /// could not be loaded: a missing file, a parse error, a
    /// corrupted bundle. Callers SHOULD retry with the fallback
    /// locale or surface a generic English string.
    LocaleUnavailable(String),
    /// A bundle loaded fine but formatting the specific key failed:
    /// an ICU MessageFormat placeholder that did not match the
    /// variable bag, a Fluent function call error, a type mismatch
    /// between a variable and the pattern that consumed it.
    FormatError(String),
    /// The backend is in a state where it cannot serve any
    /// translations right now — an async reload is in flight, a
    /// file watcher caught a broken state, a cache eviction storm.
    /// Transient; callers MAY retry.
    Unavailable(String),
    /// The backend hit something it could not classify. Treat as
    /// `Unavailable` unless you know better.
    Unknown(String),
}

impl I18nError {
    pub fn locale_unavailable(msg: impl Into<String>) -> Self {
        I18nError::LocaleUnavailable(msg.into())
    }
    pub fn format_error(msg: impl Into<String>) -> Self {
        I18nError::FormatError(msg.into())
    }
    pub fn unavailable(msg: impl Into<String>) -> Self {
        I18nError::Unavailable(msg.into())
    }
    pub fn unknown(msg: impl Into<String>) -> Self {
        I18nError::Unknown(msg.into())
    }

    pub fn message(&self) -> &str {
        match self {
            I18nError::LocaleUnavailable(m)
            | I18nError::FormatError(m)
            | I18nError::Unavailable(m)
            | I18nError::Unknown(m) => m,
        }
    }
}

impl std::fmt::Display for I18nError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let tag = match self {
            I18nError::LocaleUnavailable(_) => "locale-unavailable",
            I18nError::FormatError(_) => "format-error",
            I18nError::Unavailable(_) => "unavailable",
            I18nError::Unknown(_) => "unknown",
        };
        write!(f, "i18n {}: {}", tag, self.message())
    }
}

impl std::error::Error for I18nError {}

/// A plugin that translates keys into localized strings.
///
/// Host functions `i18n.t(key, vars)` and `i18n.locales()` call
/// through to the currently registered implementation. The host
/// layer is responsible for negotiating the request's locale from
/// headers / session / URL before calling `translate`.
pub trait I18nPlugin: Send + Sync {
    /// Unique identifier, e.g. `"static"`, `"fluent"`, `"icu"`.
    fn name(&self) -> &str;

    /// Translate `key` into `locale`, interpolating `vars`.
    ///
    /// Returns `Ok(rendered)` in the overwhelming majority of cases,
    /// including when the key is unknown — backends render missing
    /// keys as a safe marker (the key itself, or `"{key}"`) rather
    /// than returning an error, because a missing translation must
    /// never take down a UI.
    ///
    /// Returns `Err(I18nError)` only for genuine backend failures:
    /// a locale that was promised but failed to load, a format
    /// error, a backend in a transiently unavailable state.
    fn translate(
        &self,
        key: &str,
        locale: &str,
        vars: &TransVars,
    ) -> Result<String, I18nError>;

    /// All locales this plugin can serve, as BCP 47 tags
    /// (`"en"`, `"en-US"`, `"fr-CA"`, `"zh-Hant-TW"`). The order is
    /// display order, not preference order; callers negotiate via
    /// [`negotiate`](I18nPlugin::negotiate).
    fn supported_locales(&self) -> Vec<String>;

    /// The locale to use when the caller asks for one that is not
    /// in `supported_locales()` and no partial match negotiated. A
    /// BCP 47 tag; MUST appear in `supported_locales()`.
    fn fallback_locale(&self) -> String;

    /// Whether the plugin recognises the given locale tag exactly
    /// (case-insensitive). The default implementation linearly
    /// scans `supported_locales()`; backends that maintain a fast
    /// lookup structure override this.
    fn has_locale(&self, locale: &str) -> bool {
        self.supported_locales()
            .iter()
            .any(|l| l.eq_ignore_ascii_case(locale))
    }

    /// Negotiate a requested locale down to a locale the plugin can
    /// actually serve, per BCP 47 Lookup (RFC 4647 §3.4).
    ///
    /// The default implementation implements the standard walk:
    /// given `"fr-CA"`, try `"fr-CA"`, then `"fr"`, then
    /// `fallback_locale()`. Backends are free to override with a
    /// richer matcher (Fluent, for example, has its own negotiation
    /// that also considers script subtags).
    fn negotiate(&self, requested: &str) -> String {
        let mut candidate = requested.to_owned();
        loop {
            if self.has_locale(&candidate) {
                return candidate;
            }
            match candidate.rfind('-') {
                Some(idx) => candidate.truncate(idx),
                None => return self.fallback_locale(),
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn i18n_value_from_impls() {
        assert_eq!(I18nValue::from("x"), I18nValue::String("x".into()));
        assert_eq!(I18nValue::from(String::from("x")), I18nValue::String("x".into()));
        assert_eq!(I18nValue::from(5i64), I18nValue::I64(5));
        assert_eq!(I18nValue::from(5i32), I18nValue::I64(5));
        assert_eq!(I18nValue::from(5u32), I18nValue::I64(5));
        assert_eq!(I18nValue::from(1.5f64), I18nValue::F64(1.5));
        assert_eq!(I18nValue::from(true), I18nValue::Bool(true));
    }

    #[test]
    fn i18n_error_helpers_and_display() {
        let e = I18nError::locale_unavailable("en missing");
        assert!(matches!(e, I18nError::LocaleUnavailable(_)));
        assert_eq!(e.message(), "en missing");
        assert!(e.to_string().contains("locale-unavailable"));
        assert!(e.to_string().contains("en missing"));

        let f = I18nError::format_error("bad placeholder");
        assert!(matches!(f, I18nError::FormatError(_)));
    }

    /// Minimal plugin used to exercise the default `has_locale` and
    /// `negotiate` implementations. Real plugins live in
    /// `crates/bext-impls/`.
    struct FakeI18n {
        locales: Vec<String>,
        fallback: String,
    }

    impl I18nPlugin for FakeI18n {
        fn name(&self) -> &str {
            "fake"
        }
        fn translate(
            &self,
            key: &str,
            _locale: &str,
            _vars: &TransVars,
        ) -> Result<String, I18nError> {
            Ok(format!("{{{}}}", key))
        }
        fn supported_locales(&self) -> Vec<String> {
            self.locales.clone()
        }
        fn fallback_locale(&self) -> String {
            self.fallback.clone()
        }
    }

    #[test]
    fn default_has_locale_is_case_insensitive() {
        let p = FakeI18n {
            locales: vec!["en".into(), "fr-CA".into(), "zh-Hant-TW".into()],
            fallback: "en".into(),
        };
        assert!(p.has_locale("en"));
        assert!(p.has_locale("EN"));
        assert!(p.has_locale("fr-ca"));
        assert!(p.has_locale("zh-hant-tw"));
        assert!(!p.has_locale("de"));
    }

    #[test]
    fn default_negotiate_walks_to_broader_tag() {
        let p = FakeI18n {
            locales: vec!["en".into(), "fr".into()],
            fallback: "en".into(),
        };
        // Exact match wins.
        assert_eq!(p.negotiate("en"), "en");
        // fr-CA walks back to fr.
        assert_eq!(p.negotiate("fr-CA"), "fr");
        // zh-Hant-TW with no zh support falls through to fallback.
        assert_eq!(p.negotiate("zh-Hant-TW"), "en");
        // Completely unknown tag lands on fallback.
        assert_eq!(p.negotiate("de"), "en");
    }

    #[test]
    fn default_negotiate_exact_multi_tag_match() {
        let p = FakeI18n {
            locales: vec!["en".into(), "fr-CA".into(), "fr".into()],
            fallback: "en".into(),
        };
        assert_eq!(p.negotiate("fr-CA"), "fr-CA");
        assert_eq!(p.negotiate("fr-FR"), "fr");
    }
}