bones_framework/
localization.rs

1//! Localization module.
2
3use std::{
4    borrow::Cow,
5    marker::PhantomData,
6    path::PathBuf,
7    str::FromStr,
8    sync::{Arc, OnceLock},
9};
10
11use crate::prelude::*;
12
13use fluent::{FluentArgs, FluentResource};
14use intl_memoizer::concurrent::IntlLangMemoizer;
15use unic_langid::LanguageIdentifier;
16
17pub use fluent;
18pub use fluent::fluent_args;
19pub use fluent_langneg;
20pub use intl_memoizer;
21pub use sys_locale;
22pub use unic_langid;
23
24/// Specialization of of the fluent bundle that is used by bones_framework.
25pub type FluentBundle = fluent::bundle::FluentBundle<FluentResourceAsset, IntlLangMemoizer>;
26
27/// An asset containing a [`FluentResource`].
28#[derive(HasSchema, Deref, DerefMut, Clone)]
29#[schema(opaque, no_default)]
30#[type_data(asset_loader(["ftl"], FluentResourceLoader))]
31pub struct FluentResourceAsset(pub Arc<FluentResource>);
32impl std::borrow::Borrow<FluentResource> for FluentResourceAsset {
33    fn borrow(&self) -> &FluentResource {
34        &self.0
35    }
36}
37
38/// An asset containing a [`FluentBundle`].
39#[derive(HasSchema, Deref, DerefMut, Clone)]
40#[schema(opaque, no_default)]
41#[type_data(asset_loader(["locale.yaml", "locale.yml"], FluentBundleLoader))]
42pub struct FluentBundleAsset(pub Arc<FluentBundle>);
43
44/// Asset containing all loaded localizations, and functions for formatting localized messages.
45#[derive(HasSchema, Deref, DerefMut, Clone)]
46#[schema(opaque, no_default)]
47#[type_data(asset_loader(["localization.yaml", "localization.yml"], LocalizationLoader))]
48pub struct LocalizationAsset {
49    /// The bundle selected as the current language.
50    #[deref]
51    pub current_bundle: FluentBundleAsset,
52    /// The bundles for all loaded languages.
53    pub bundles: Arc<[FluentBundleAsset]>,
54}
55
56impl LocalizationAsset {
57    /// Get a localized message.
58    pub fn get(&self, id: &str) -> Cow<'_, str> {
59        let b = &self.current_bundle.0;
60        let Some(message) = b.get_message(id) else {
61            return Cow::from("");
62        };
63        let Some(value) = message.value() else {
64            return Cow::from("");
65        };
66
67        // TODO: Log localization formatting errors.
68        // We need to find a way to log the errors without allocating every time we format:
69        // https://github.com/projectfluent/fluent-rs/issues/323.
70        b.format_pattern(value, None, &mut vec![])
71    }
72
73    /// Get a localized message with the provided arguments.
74    pub fn get_with<'a>(&'a self, id: &'a str, args: &'a FluentArgs) -> Cow<'a, str> {
75        let b = &self.current_bundle.0;
76        let Some(message) = b.get_message(id) else {
77            return Cow::from("");
78        };
79        let Some(value) = message.value() else {
80            return Cow::from("");
81        };
82
83        b.format_pattern(value, Some(args), &mut vec![])
84    }
85}
86
87use dashmap::mapref::one::MappedRef;
88/// Borrow the localization field from the root asset.
89///
90/// This parameter uses the schema implementation to find the field of the root asset that is a
91/// [`Handle<LocalizationAsset>`].
92#[derive(Deref, DerefMut)]
93pub struct Localization<'a, T> {
94    #[deref]
95    asset: MappedRef<'a, Cid, LoadedAsset, LocalizationAsset>,
96    _phantom: PhantomData<T>,
97}
98
99/// Internal resource used to cache the field of the root asset containing the localization resource
100/// for the [`Localization`] parameter.
101#[derive(HasSchema, Default, Clone)]
102pub struct RootLocalizationFieldIdx(OnceLock<usize>);
103
104impl<T: HasSchema> SystemParam for Localization<'_, T> {
105    type State = (AssetServer, AtomicResource<RootLocalizationFieldIdx>);
106    type Param<'s> = Localization<'s, T>;
107
108    fn get_state(world: &World) -> Self::State {
109        (
110            (*world.resources.get::<AssetServer>().unwrap()).clone(),
111            world.resources.get_cell::<RootLocalizationFieldIdx>(),
112        )
113    }
114    fn borrow<'s>(
115        world: &'s World,
116        (asset_server, field_idx): &'s mut Self::State,
117    ) -> Self::Param<'s> {
118        const ERR: &str = "Could not find a `Handle<LocalizationAsset>` field on root asset, \
119                           needed for `Localization` parameter to work";
120        let field_idx = field_idx.init_borrow(world);
121        let field_idx = field_idx.0.get_or_init(|| {
122            let mut idx = None;
123            for (i, field) in T::schema()
124                .kind
125                .as_struct()
126                .expect(ERR)
127                .fields
128                .iter()
129                .enumerate()
130            {
131                if let Some(handle_data) = field.schema.type_data.get::<SchemaAssetHandle>() {
132                    if let Some(schema) = handle_data.inner_schema() {
133                        if schema == LocalizationAsset::schema() {
134                            idx = Some(i);
135                            break;
136                        }
137                    }
138                }
139            }
140            idx.expect(ERR)
141        });
142
143        let root = asset_server.root::<T>();
144        let root = root.as_schema_ref();
145        let handle = root
146            .field(*field_idx)
147            .expect(ERR)
148            .cast::<Handle<LocalizationAsset>>();
149        let asset = asset_server.get(*handle);
150
151        Localization {
152            asset,
153            _phantom: PhantomData,
154        }
155    }
156}
157
158struct FluentResourceLoader;
159impl AssetLoader for FluentResourceLoader {
160    fn load(&self, _ctx: AssetLoadCtx, bytes: &[u8]) -> BoxedFuture<anyhow::Result<SchemaBox>> {
161        let bytes = bytes.to_vec();
162        Box::pin(async move {
163            let string = String::from_utf8(bytes).context("Error loading fluent resource file.")?;
164            let res = FluentResource::try_new(string).map_err(|(_, errors)| {
165                let errors = errors
166                    .into_iter()
167                    .map(|e| e.to_string())
168                    .collect::<Vec<_>>()
169                    .join("\n");
170
171                anyhow::format_err!("Error loading fluent resource file. \n{}", errors)
172            })?;
173
174            Ok(SchemaBox::new(FluentResourceAsset(Arc::new(res))))
175        })
176    }
177}
178
179struct FluentBundleLoader;
180impl AssetLoader for FluentBundleLoader {
181    fn load(&self, mut ctx: AssetLoadCtx, bytes: &[u8]) -> BoxedFuture<anyhow::Result<SchemaBox>> {
182        let bytes = bytes.to_vec();
183        Box::pin(async move {
184            let self_path = ctx.loc.path.clone();
185            #[derive(serde::Serialize, serde::Deserialize)]
186            struct BundleMeta {
187                pub locales: Vec<LanguageIdentifier>,
188                pub resources: Vec<PathBuf>,
189            }
190            let meta: BundleMeta =
191                serde_yaml::from_slice(&bytes).context("Could not parse locale YAML")?;
192
193            let mut bundle = FluentBundle::new_concurrent(meta.locales);
194
195            for resource_path in meta.resources {
196                let normalized = resource_path
197                    .absolutize_from(self_path.parent().unwrap())
198                    .unwrap();
199                let resource_handle = ctx.load_asset(&normalized)?.typed::<FluentResourceAsset>();
200                let resource = loop {
201                    if let Some(resource) = ctx.asset_server.try_get(resource_handle) {
202                        break resource.context("FluentBundle resource `{normalized:?}` handle could not be cast to `FluentResourceAsset`")?;
203                    }
204                    ctx.asset_server.load_progress.listen().await;
205                };
206                bundle.add_resource(resource.clone()).map_err(|e| {
207                    anyhow::format_err!(
208                    "Error(s) adding resource `{normalized:?}` to bundle `{self_path:?}`: {e:?}"
209                )
210                })?;
211            }
212
213            Ok(SchemaBox::new(FluentBundleAsset(Arc::new(bundle))))
214        })
215    }
216}
217
218struct LocalizationLoader;
219impl AssetLoader for LocalizationLoader {
220    fn load(&self, mut ctx: AssetLoadCtx, bytes: &[u8]) -> BoxedFuture<anyhow::Result<SchemaBox>> {
221        let bytes = bytes.to_vec();
222        Box::pin(async move {
223            let self_path = ctx.loc.path.clone();
224            #[derive(serde::Serialize, serde::Deserialize)]
225            struct LocalizationMeta {
226                pub locales: Vec<PathBuf>,
227            }
228            let meta: LocalizationMeta =
229                serde_yaml::from_slice(&bytes).context("Could not parse locale YAML")?;
230
231            let mut bundles: Vec<FluentBundleAsset> = Vec::new();
232
233            for bundle_path in meta.locales {
234                let normalized = bundle_path
235                    .absolutize_from(self_path.parent().unwrap())
236                    .unwrap();
237                let bundle_handle = ctx.load_asset(&normalized)?.typed::<FluentBundleAsset>();
238                let bundle = loop {
239                    if let Some(bundle) = ctx.asset_server.try_get(bundle_handle) {
240                        break bundle.context("Localization resource `{normalized:?}` loaded handle could not be cast to `FluentBundleAsset`")?;
241                    }
242                    ctx.asset_server.load_progress.listen().await;
243                };
244                bundles.push(bundle.clone());
245            }
246
247            let available_locales = bundles
248                .iter()
249                .flat_map(|x| x.locales.iter())
250                .cloned()
251                .collect::<Vec<_>>();
252
253            let en_us = LanguageIdentifier::from_str("en-US").unwrap();
254            let user_locale = sys_locale::get_locale()
255                .and_then(|x| x.parse::<LanguageIdentifier>().ok())
256                .unwrap_or(en_us.clone());
257
258            let selected_locale = fluent_langneg::negotiate_languages(
259                std::slice::from_ref(&user_locale),
260                &available_locales,
261                Some(&en_us),
262                fluent_langneg::NegotiationStrategy::Filtering,
263            )[0];
264
265            let selected_bundle = bundles
266                .iter()
267                .find(|bundle| bundle.locales.contains(selected_locale))
268                .ok_or_else(|| {
269                    anyhow::format_err!("Could not find matching locale for {user_locale}")
270                })?;
271
272            Ok(SchemaBox::new(LocalizationAsset {
273                current_bundle: selected_bundle.clone(),
274                bundles: bundles.into_iter().collect(),
275            }))
276        })
277    }
278}