Skip to main content

rstest_bdd/
localization.rs

1//! Localization utilities used by the public macros and runtime diagnostics.
2
3use std::cell::RefCell;
4use std::sync::{LazyLock, RwLock};
5
6use fluent::FluentArgs;
7use i18n_embed::I18nEmbedError;
8use i18n_embed::fluent::{FluentLanguageLoader, fluent_language_loader};
9use rust_embed::RustEmbed;
10use thiserror::Error;
11use unic_langid::LanguageIdentifier;
12
13/// Embedded Fluent resources shipped with the crate.
14///
15/// The struct implements [`RustEmbed`], allowing callers to seed a
16/// [`FluentLanguageLoader`] with the bundled locale files.
17///
18/// # Examples
19/// ```
20/// # use rstest_bdd::localization::Localizations;
21/// # use i18n_embed::fluent::fluent_language_loader;
22/// # use unic_langid::langid;
23/// let mut loader = fluent_language_loader!();
24/// let selected = i18n_embed::select(&loader, &Localizations, &[langid!("en-US")]).unwrap();
25/// assert!(selected.contains(&langid!("en-US")));
26/// ```
27#[derive(RustEmbed)]
28#[folder = "i18n"]
29pub struct Localizations;
30
31static LANGUAGE_LOADER: LazyLock<RwLock<FluentLanguageLoader>> = LazyLock::new(|| {
32    let loader = fluent_language_loader!();
33    i18n_embed::select(&loader, &Localizations, &[unic_langid::langid!("en-US")])
34        .unwrap_or_else(|error| panic!("failed to load default English translations: {error}"));
35    RwLock::new(loader)
36});
37
38thread_local! {
39    static OVERRIDE_LOADER: RefCell<Option<FluentLanguageLoader>> = const { RefCell::new(None) };
40}
41
42/// Errors from localization setup and queries.
43#[derive(Debug, Error)]
44pub enum LocalizationError {
45    /// Global or thread-local localization state was poisoned.
46    #[error("localization state is poisoned")]
47    Poisoned,
48    /// Loading or selecting Fluent resources failed.
49    #[error("failed to load localization resources: {0}")]
50    Loader(#[from] I18nEmbedError),
51}
52
53/// RAII guard that installs a thread-local localization loader for the
54/// lifetime of the guard.
55#[must_use]
56pub struct ScopedLocalization {
57    previous: Option<FluentLanguageLoader>,
58}
59
60impl ScopedLocalization {
61    /// Load the requested locales into a dedicated loader and make it the
62    /// active loader for the current thread.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`LocalizationError::Loader`] if localization resources cannot
67    /// be loaded for the requested languages.
68    pub fn new(requested: &[LanguageIdentifier]) -> Result<Self, LocalizationError> {
69        let loader = fluent_language_loader!();
70        i18n_embed::select(&loader, &Localizations, requested)?;
71        let previous = OVERRIDE_LOADER.with(|cell| cell.replace(Some(loader)));
72        Ok(Self { previous })
73    }
74}
75
76impl Drop for ScopedLocalization {
77    fn drop(&mut self) {
78        let previous = self.previous.take();
79        OVERRIDE_LOADER.with(|cell| {
80            *cell.borrow_mut() = previous;
81        });
82    }
83}
84
85/// Replace the global localization loader with a preconfigured instance.
86///
87/// # Errors
88///
89/// Returns [`LocalizationError::Poisoned`] when the global loader lock is poisoned.
90pub fn install_localization_loader(loader: FluentLanguageLoader) -> Result<(), LocalizationError> {
91    let mut guard = LANGUAGE_LOADER
92        .write()
93        .map_err(|_| LocalizationError::Poisoned)?;
94    *guard = loader;
95    Ok(())
96}
97
98/// Activate the best matching localizations for the provided language identifiers.
99///
100/// # Errors
101///
102/// Returns [`LocalizationError::Poisoned`] if the global loader lock is poisoned
103/// or [`LocalizationError::Loader`] when resource selection fails.
104pub fn select_localizations(
105    requested: &[LanguageIdentifier],
106) -> Result<Vec<LanguageIdentifier>, LocalizationError> {
107    OVERRIDE_LOADER.with(|cell| -> Result<_, LocalizationError> {
108        if let Some(loader) = cell.borrow_mut().as_mut() {
109            let selected = i18n_embed::select(loader, &Localizations, requested)?;
110            return Ok(selected);
111        }
112        let guard = LANGUAGE_LOADER
113            .read()
114            .map_err(|_| LocalizationError::Poisoned)?;
115        let selected = i18n_embed::select(&*guard, &Localizations, requested)?;
116        Ok(selected)
117    })
118}
119
120/// Query the currently active localizations.
121///
122/// # Errors
123///
124/// Returns [`LocalizationError::Poisoned`] if the loader lock is poisoned.
125pub fn current_languages() -> Result<Vec<LanguageIdentifier>, LocalizationError> {
126    OVERRIDE_LOADER.with(|cell| -> Result<_, LocalizationError> {
127        if let Some(loader) = cell.borrow().as_ref() {
128            return Ok(loader.current_languages());
129        }
130        let guard = LANGUAGE_LOADER
131            .read()
132            .map_err(|_| LocalizationError::Poisoned)?;
133        Ok(guard.current_languages())
134    })
135}
136
137#[must_use]
138/// Retrieve a localised string without interpolation arguments.
139///
140/// # Examples
141/// ```
142/// # use rstest_bdd::localization;
143/// assert_eq!(
144///     localization::message("placeholder-pattern-mismatch"),
145///     "pattern mismatch"
146/// );
147/// ```
148pub fn message(id: &str) -> String {
149    with_loader(|loader| loader.get(id))
150}
151
152#[must_use]
153/// Retrieve a localised string with Fluent arguments supplied via a closure.
154///
155/// # Examples
156/// ```
157/// # use rstest_bdd::localization;
158/// let rendered = localization::message_with_args("panic-message-opaque-payload", |args| {
159///     args.set("type", "Example".to_string());
160/// });
161/// assert!(rendered.contains("Example"));
162/// ```
163pub fn message_with_args<F>(id: &str, configure: F) -> String
164where
165    F: FnOnce(&mut FluentArgs<'static>),
166{
167    with_loader(|loader| message_with_loader(loader, id, configure))
168}
169
170pub(crate) fn message_with_loader<F>(
171    loader: &FluentLanguageLoader,
172    id: &str,
173    configure: F,
174) -> String
175where
176    F: FnOnce(&mut FluentArgs<'static>),
177{
178    let mut args: FluentArgs<'static> = FluentArgs::new();
179    configure(&mut args);
180    loader.get_args_fluent(id, Some(&args))
181}
182
183pub(crate) fn with_loader<R>(callback: impl FnOnce(&FluentLanguageLoader) -> R) -> R {
184    OVERRIDE_LOADER.with(|cell| {
185        let borrow = cell.borrow();
186        if let Some(loader) = borrow.as_ref() {
187            return callback(loader);
188        }
189        drop(borrow);
190        let guard = LANGUAGE_LOADER
191            .read()
192            .unwrap_or_else(std::sync::PoisonError::into_inner);
193        callback(&guard)
194    })
195}
196
197/// Remove Unicode directional isolates inserted by Fluent during interpolation.
198#[must_use]
199pub fn strip_directional_isolates(text: &str) -> String {
200    text.chars()
201        .filter(|c| !matches!(*c, '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}'))
202        .collect()
203}
204
205/// Panic with a localized message resolved from a Fluent ID and key–value args.
206#[macro_export]
207macro_rules! panic_localized {
208    ($id:expr $(, $key:ident = $value:expr )* $(,)?) => {{
209        let message = $crate::localization::message_with_args($id, |args| {
210            $( args.set(stringify!($key), $value.to_string()); )*
211        });
212        panic!("{message}");
213    }};
214}