hephae_locale/
def.rs

1//! Defines all the necessary components for localization to work, namely:
2//!
3//! - [`Locale`], maps locale keys with (potentially formatted) localized strings.
4//! - [`LocaleCollection`], maps locale codes (e.g., `en-US`, `id-ID`) with [`Locale`] asset
5//!   handles.
6//! - [`LocaleKey`], holds a reference to a locale collection and locale key.
7//! - [`LocaleResult`], caches the localized result.
8
9use std::{any::type_name, borrow::Cow, ops::Range, slice::Iter};
10
11use bevy_asset::{ReflectAsset, UntypedAssetId, VisitAssetDependencies, prelude::*};
12use bevy_derive::{Deref, DerefMut};
13use bevy_ecs::{
14    component::ComponentId,
15    entity::VisitEntitiesMut,
16    prelude::*,
17    reflect::{ReflectMapEntities, ReflectVisitEntities, ReflectVisitEntitiesMut},
18    world::DeferredWorld,
19};
20use bevy_reflect::prelude::*;
21use bevy_utils::{HashMap, warn_once};
22use scopeguard::{Always, ScopeGuard};
23use smallvec::SmallVec;
24use thiserror::Error;
25
26use crate::arg::LocaleArg;
27
28/// Maps locale keys with (potentially formatted) localized strings. See [`LocaleFmt`] for the
29/// syntax.
30#[derive(Asset, Reflect, Deref, DerefMut, Debug)]
31#[reflect(Asset, Debug)]
32pub struct Locale(pub HashMap<String, LocaleFmt>);
33impl Locale {
34    /// Formats a localization string with the provided arguments into an output [`String`].
35    pub fn localize_into(&self, key: impl AsRef<str>, args_src: &[&str], out: &mut String) -> Result<(), LocalizeError> {
36        match self.get(key.as_ref()).ok_or(LocalizeError::MissingKey)? {
37            LocaleFmt::Unformatted(res) => {
38                out.clone_from(res);
39                Ok(())
40            }
41            LocaleFmt::Formatted { format, args } => {
42                let len = args.iter().try_fold(0, |mut len, &(ref range, i)| {
43                    len += range.end - range.start;
44                    len += args_src.get(i).ok_or(LocalizeError::MissingArgument(i))?.len();
45                    Ok(len)
46                })?;
47
48                out.clear();
49                out.reserve_exact(len);
50
51                let mut last = 0;
52                for &(ref range, i) in args {
53                    // Some sanity checks in case some users for some reason modify the locales manually.
54                    let start = range.start.min(format.len());
55                    let end = range.end.min(format.len());
56                    last = last.max(end);
57
58                    // All these unwraps shouldn't panic.
59                    out.push_str(&format[start..end]);
60                    out.push_str(args_src[i]);
61                }
62                out.push_str(&format[last..]);
63
64                Ok(())
65            }
66        }
67    }
68
69    /// Convenient shortcut for [`localize_into`](Self::localize_into) that allocates a new
70    /// [`String`].
71    #[inline]
72    pub fn localize(&self, key: impl AsRef<str>, args_src: &[&str]) -> Result<String, LocalizeError> {
73        let mut out = String::new();
74        self.localize_into(key, args_src, &mut out)?;
75        Ok(out)
76    }
77}
78
79/// Errors that may arise from [`Locale::localize_into`].
80#[derive(Error, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
81pub enum LocalizeError {
82    /// The locale doesn't contain the localization for the supplied key.
83    #[error("The locale doesn't contain the localization for the supplied key.")]
84    MissingKey,
85    /// Missing argument at the given index.
86    #[error("Missing argument at index {0}.")]
87    MissingArgument(usize),
88}
89
90/// A locale string, either unformatted or formatted.
91///
92/// # Syntax
93///
94/// The syntax is similar to a subset of [`format!`]; everything is the same, except that in
95/// arguments, only explicitly-indexed positional arguments are supported.
96///
97/// ```
98/// use std::str::FromStr;
99///
100/// use bevy_utils::HashMap;
101/// use hephae_locale::{
102///     def::{LocaleFmt, LocalizeError},
103///     prelude::*,
104/// };
105///
106/// let a = LocaleFmt::from_str("Hi {0}, this is {1}. {5}...").unwrap();
107/// let b = LocaleFmt::from_str("It's nice to meet you {{inside these braces for no reason}}.")
108///     .unwrap();
109/// LocaleFmt::from_str("Can't use {this}, can't use {that:?}, can't use {} either!").unwrap_err();
110///
111/// let loc = Locale(HashMap::from_iter([
112///     (String::from("greet"), a),
113///     (String::from("chitchat"), b),
114/// ]));
115///
116/// // Format the arguments with `Locale::localize`. Unnecessary arguments are ignored.
117/// assert_eq!(
118///     loc.localize("greet", &[
119///         "Joe", "Jane", "these", "are", "ignored", "Hehehe"
120///     ])
121///     .unwrap(),
122///     "Hi Joe, this is Jane. Hehehe..."
123/// );
124///
125/// // Double braces are escaped into single braces.
126/// assert_eq!(
127///     loc.localize("chitchat", &[]).unwrap(),
128///     "It's nice to meet you {inside these braces for no reason}."
129/// );
130///
131/// // Missing key will not panic.
132/// assert_eq!(
133///     loc.localize("missing", &[]).unwrap_err(),
134///     LocalizeError::MissingKey
135/// );
136///
137/// // Neither will missing arguments.
138/// assert_eq!(
139///     loc.localize("greet", &[]).unwrap_err(),
140///     LocalizeError::MissingArgument(0),
141/// );
142/// ```
143#[derive(Reflect, Clone, Debug)]
144#[reflect(Debug)]
145pub enum LocaleFmt {
146    /// Locale string with no arguments.
147    Unformatted(String),
148    /// Locale string with arguments. It is advisable to use
149    /// [`LocaleFmt::from_str`](std::str::FromStr::from_str) to create instead of doing so manually.
150    Formatted {
151        /// The locale string format, without the positional argument markers.
152        format: String,
153        /// The pairs of format span and index of the argument to be appended.
154        args: Vec<(Range<usize>, usize)>,
155    },
156}
157
158/// Collection of [`Locale`]s, mapped by their locale codes.
159#[derive(Reflect, Debug)]
160#[reflect(Asset, Debug)]
161pub struct LocaleCollection {
162    /// The default locale code to use.
163    pub default: String,
164    /// The [`Locale`] map.
165    pub languages: HashMap<String, Handle<Locale>>,
166}
167
168impl Asset for LocaleCollection {}
169impl VisitAssetDependencies for LocaleCollection {
170    #[inline]
171    fn visit_dependencies(&self, visit: &mut impl FnMut(UntypedAssetId)) {
172        self.languages.values().for_each(|handle| visit(handle.id().untyped()))
173    }
174}
175
176/// Firing this event will cause all [`LocaleKey`]s to update their results for the new locale code.
177#[derive(Event, Reflect, Clone, Debug)]
178#[reflect(Debug)]
179pub struct LocaleChangeEvent(pub String);
180
181/// Stores the locale key and the handle to a [`LocaleCollection`] as a component to be processed in
182/// the pipeline.
183///
184/// Using [`Commands::spawn_localized`](crate::cmd::LocCommandsExt::spawn_localized) is advisable.
185#[derive(Component, Reflect, Clone, Deref, DerefMut, Debug)]
186#[component(on_remove = remove_localize)]
187#[require(LocaleResult)]
188#[reflect(Component, Debug)]
189pub struct LocaleKey {
190    /// The locale key to fetch and format from. In case of a missing key, [`LocaleResult::result`]
191    /// will be empty and a warning will be printed.
192    #[deref]
193    pub key: Cow<'static, str>,
194    /// The handle to the [`LocaleCollection`].
195    pub collection: Handle<LocaleCollection>,
196}
197
198fn remove_localize(mut world: DeferredWorld, e: Entity, _: ComponentId) {
199    let args = std::mem::take(&mut world.get_mut::<LocaleArgs>(e).unwrap().0);
200    world.commands().entity(e).queue(move |e: Entity, world: &mut World| {
201        world.entity_mut(e).remove::<LocaleArgs>();
202        for arg in args {
203            world.despawn(arg);
204        }
205    });
206}
207
208/// Formatted localized string, ready to be used.
209///
210/// Using [`Commands::spawn_localized`](crate::cmd::LocCommandsExt::spawn_localized) is advisable.
211#[derive(Component, Reflect, Clone, Default, Deref, DerefMut, Debug)]
212#[reflect(Component, Default, Debug)]
213pub struct LocaleResult {
214    /// The result string fetched from the [collection](LocaleKey::collection) by a
215    /// [key](LocaleKey::key).
216    #[deref]
217    pub result: String,
218    #[reflect(ignore)]
219    changed: bool,
220    #[reflect(ignore)]
221    locale: AssetId<Locale>,
222}
223
224#[derive(Component, Reflect, Clone, VisitEntitiesMut)]
225#[reflect(Component, MapEntities, VisitEntities, VisitEntitiesMut)]
226pub(crate) struct LocaleArgs(pub SmallVec<[Entity; 4]>);
227impl<'a> IntoIterator for &'a LocaleArgs {
228    type Item = <Self::IntoIter as Iterator>::Item;
229    type IntoIter = Iter<'a, Entity>;
230
231    #[inline]
232    fn into_iter(self) -> Self::IntoIter {
233        self.0.iter()
234    }
235}
236
237#[derive(Component, Reflect, Deref)]
238#[require(LocaleCache)]
239#[reflect(Component)]
240pub(crate) struct LocaleSrc<T: LocaleArg>(pub T);
241
242#[derive(Component, Default)]
243pub(crate) struct LocaleCache {
244    pub result: Option<String>,
245    pub locale: AssetId<Locale>,
246    pub changed: bool,
247}
248
249pub(crate) fn update_locale_asset(
250    mut collection_events: EventReader<AssetEvent<LocaleCollection>>,
251    mut locale_events: EventReader<AssetEvent<Locale>>,
252    mut change_events: EventReader<LocaleChangeEvent>,
253    locales: Res<Assets<LocaleCollection>>,
254    mut localize_query: Query<(Ref<LocaleKey>, &mut LocaleResult, &LocaleArgs)>,
255    mut cache_query: Query<&mut LocaleCache>,
256    mut last: Local<Option<String>>,
257) {
258    let new_id = (!change_events.is_empty()).then(|| {
259        let mut iter = change_events.read();
260        let mut last = iter.next().expect("`events.is_empty()` returned false");
261
262        for next in iter {
263            last = next;
264        }
265
266        &last.0
267    });
268
269    let mut all_change = if let Some(new_id) = new_id {
270        last.get_or_insert_default().clone_from(new_id);
271        true
272    } else {
273        false
274    };
275
276    // Events happening to both of these asset types are very unlikely. Always assume it's a change to
277    // save maintainers from severe migraines.
278    if !collection_events.is_empty() || !locale_events.is_empty() {
279        collection_events.clear();
280        locale_events.clear();
281        all_change = true;
282    }
283
284    for (loc, mut result, args) in &mut localize_query {
285        if all_change || loc.is_changed() {
286            let locale_id = locales
287                .get(&loc.collection)
288                .and_then(|collection| {
289                    collection
290                        .languages
291                        .get(new_id.unwrap_or(last.as_ref().unwrap_or(&collection.default)))
292                })
293                .map(Handle::id)
294                .unwrap_or_default();
295
296            for &e in args {
297                // Don't use `Query::iter_many_mut` here to preserve argument index.
298                // The aforementioned method will skip despawned entities which is unfavorable.
299                let Ok(mut cache) = cache_query.get_mut(e) else {
300                    continue;
301                };
302
303                if all_change {
304                    // Signal the cache for refreshing.
305                    let cache = cache.bypass_change_detection();
306                    cache.changed = true;
307                    cache.locale = locale_id;
308                }
309            }
310
311            // Mark as changed without alerting `Changed<T>` temporarily.
312            let result = result.bypass_change_detection();
313            result.changed = true;
314            result.locale = locale_id;
315        }
316    }
317}
318
319pub(crate) fn update_locale_cache<T: LocaleArg>(
320    locales: Res<Assets<Locale>>,
321    mut sources: Query<(Entity, &LocaleSrc<T>, &mut LocaleCache)>,
322) {
323    for (e, src, mut cache) in &mut sources {
324        let cache = cache.bypass_change_detection();
325        if !cache.changed {
326            continue;
327        }
328
329        cache.changed = false;
330
331        let Some(locale) = locales.get(cache.locale) else {
332            cache.result = None;
333            continue;
334        };
335
336        let result = cache.result.get_or_insert_default();
337        result.clear();
338
339        if src.localize_into(locale, result).is_err() {
340            result.clear();
341            warn_once!("An error occurred while trying to format {} in {e}", type_name::<T>());
342        }
343    }
344}
345
346pub(crate) fn update_locale_result(
347    locales: Res<Assets<Locale>>,
348    mut result: Query<(Entity, &LocaleKey, &mut LocaleResult, &LocaleArgs)>,
349    cache_query: Query<&LocaleCache>,
350    mut arguments: Local<Vec<&'static str>>,
351) {
352    /// Delegates [`std::mem::transmute`] to shrink the vector element's lifetime, but with
353    /// invariant mutable reference lifetime to the vector so it may not be accessed while the
354    /// guard is active.
355    ///
356    /// # Safety:
357    /// - The guard must **not** be passed anywhere else. Ideally, you'd want to immediately
358    ///   dereference it just to make sure.
359    /// - The drop glue of the guard must be called, i.e., [`std::mem::forget`] may not be called.
360    ///   This is to ensure the `'a` lifetime objects are cleared out.
361    #[inline]
362    #[allow(unsafe_op_in_unsafe_fn)]
363    unsafe fn guard<'a, 'this: 'a>(
364        spans: &'this mut Vec<&'static str>,
365    ) -> ScopeGuard<&'this mut Vec<&'a str>, fn(&mut Vec<&'a str>), Always> {
366        // Safety: We only change the lifetime, so the value is valid for both types.
367        ScopeGuard::with_strategy(
368            std::mem::transmute::<&'this mut Vec<&'static str>, &'this mut Vec<&'a str>>(spans),
369            Vec::clear,
370        )
371    }
372
373    // Safety: The guard is guaranteed not to be dropped early since it's immediately dereferenced.
374    let arguments = &mut **unsafe { guard(&mut arguments) };
375    'outer: for (e, loc, result, args) in &mut result {
376        if !result.changed {
377            continue 'outer;
378        };
379
380        // Alert `Changed<T>` so systems can listen to it.
381        let result = result.into_inner();
382        result.changed = false;
383
384        // Don't `warn!`; assume the locale asset hasn't loaded yet.
385        let Some(locale) = locales.get(result.locale) else {
386            result.clear();
387            continue 'outer;
388        };
389
390        arguments.clear();
391        for &arg in args {
392            // Don't use `Query::iter_many_mut` here to preserve argument index.
393            // The aforementioned method will skip despawned entities which is unfavorable.
394            let Ok(cache) = cache_query.get(arg) else {
395                warn_once!("Locale argument {arg} missing for entity {e}");
396
397                result.clear();
398                continue 'outer;
399            };
400
401            let Some(ref result) = cache.result else {
402                warn_once!("Locale argument {arg} failed to localize for entity {e}");
403
404                result.clear();
405                continue 'outer;
406            };
407
408            arguments.push(result);
409        }
410
411        if let Err(error) = locale.localize_into(&loc.key, arguments, result) {
412            warn_once!("Couldn't localize {e}: {error}");
413            result.clear();
414        }
415    }
416}