poly_l10n/
lib.rs

1//! `poly_l10n`: Handle locali(s|z)ations the correct way
2//!
3//! ## Intentions
4//!
5//! See <https://blog.fyralabs.com/advice-on-internationalization/#language-fallbacks>.
6//!
7//! In short, this crate handles language fallbacks and detect system languages *the correct way*.
8//!
9//! Get started by [`LocaleFallbackSolver`], [`system_want_langids()`] and [`langid!`].
10//!
11//! ## 📃 License
12//!
13//! `GPL-3.0-or-later`
14//!
15//!    Copyright (C) 2025  madonuko <mado@fyralabs.com> <madonuko@outlook.com>
16//!
17//!    This program is free software: you can redistribute it and/or modify
18//!    it under the terms of the GNU General Public License as published by
19//!    the Free Software Foundation, either version 3 of the License, or
20//!    (at your option) any later version.
21//!
22//!    This program is distributed in the hope that it will be useful,
23//!    but WITHOUT ANY WARRANTY; without even the implied warranty of
24//!    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
25//!    GNU General Public License for more details.
26//!
27//!    You should have received a copy of the GNU General Public License
28//!    along with this program.  If not, see <https://www.gnu.org/licenses/>.
29
30mod default_rulebook;
31#[cfg(feature = "getlang")]
32pub mod getlang;
33pub mod macros;
34#[cfg(feature = "per_lang_default_rules")]
35pub mod per_lang_default_rules;
36
37use std::{rc::Rc, sync::Arc};
38
39#[cfg(feature = "getlang")]
40pub use getlang::system_want_langids;
41use itertools::Itertools;
42pub use unic_langid::{self, LanguageIdentifier};
43
44/// Entry point of `poly_l10n`.
45///
46/// A solver requires a [`Rulebook`] or [`ARulebook`] to process and solve locales. The latter is
47/// used by [`Self::default`].
48///
49/// # Examples
50/// ```
51/// let solver = poly_l10n::LocaleFallbackSolver::<poly_l10n::ARulebook>::default();
52/// # #[cfg(feature = "per_lang_default_rules")]
53/// assert_eq!(solver.solve_locale(poly_l10n::langid!("arb")), poly_l10n::langid!["arb", "ar-AE", "ara-AE", "arb-AE", "ar", "ara"]);
54/// ```
55#[derive(Clone, Copy, Debug, Default)]
56pub struct LocaleFallbackSolver<R: for<'a> PolyL10nRulebook<'a> = ARulebook> {
57    pub rulebook: R,
58}
59
60impl<R: for<'a> PolyL10nRulebook<'a>> LocaleFallbackSolver<R> {
61    /// Find alternative fallbacks for the given `locale` as specified by the `rulebook`. This
62    /// operation is recursive and expensive.
63    ///
64    /// ```
65    /// let solver = poly_l10n::LocaleFallbackSolver::<poly_l10n::Rulebook>::default();
66    /// # #[cfg(feature = "per_lang_default_rules")]
67    /// assert_eq!(solver.solve_locale(poly_l10n::langid!("arb")), poly_l10n::langid!["arb", "ar-AE", "ara-AE", "arb-AE", "ar", "ara"]);
68    /// ```
69    pub fn solve_locale<L: AsRef<LanguageIdentifier>>(&self, locale: L) -> Vec<LanguageIdentifier> {
70        use std::hash::{Hash, Hasher};
71        let locale = locale.as_ref();
72        let mut locales = self.rulebook.find_fallback_locale(locale).collect_vec();
73        let h = |l: &LanguageIdentifier| {
74            let mut hasher = std::hash::DefaultHasher::default();
75            l.hash(&mut hasher);
76            hasher.finish()
77        };
78        let mut locale_hashes = locales.iter().map(h).collect_vec();
79        let mut old_len = 0;
80        while old_len != locales.len() {
81            #[allow(clippy::indexing_slicing)]
82            let new_locales = locales[old_len..]
83                .iter()
84                .flat_map(|locale| {
85                    self.rulebook.find_fallback_locale(locale).chain(
86                        self.rulebook
87                            .find_fallback_locale_ref(locale)
88                            .map(Clone::clone),
89                    )
90                })
91                .filter(|l| !locale_hashes.contains(&h(l)))
92                .unique()
93                .collect_vec();
94            old_len = locales.len();
95            locales.extend_from_slice(&new_locales);
96            locale_hashes.extend(new_locales.iter().map(h));
97        }
98        locales.into_iter().unique().collect_vec()
99    }
100}
101
102/// Rulebook trait.
103///
104/// A rulebook is a set of rules for [`LocaleFallbackSolver`]. The solver obtains the list of
105/// fallback locales from the rules in the solver's rulebook.
106///
107/// The default rulebook is [`ARulebook`] and you may create a solver with it using:
108///
109/// ```
110/// poly_l10n::LocaleFallbackSolver::<poly_l10n::ARulebook>::default()
111/// # ;
112/// ```
113///
114/// With that being said, a custom tailor-made rulebook is possible by implementing this trait for
115/// a new struct.
116///
117/// # Implementation
118/// Only one of [`PolyL10nRulebook::find_fallback_locale`] and
119/// [`PolyL10nRulebook::find_fallback_locale_ref`] SHOULD be implemented. Note that for the latter,
120/// [`LocaleFallbackSolver`] will clone the items in the returned iterator, so there are virtually
121/// no performance difference between the two.
122///
123/// If both functions are implemented, the solver will [`Iterator::chain`] them together.
124pub trait PolyL10nRulebook<'s> {
125    fn find_fallback_locale(
126        &self,
127        _: &LanguageIdentifier,
128    ) -> impl Iterator<Item = LanguageIdentifier> {
129        std::iter::empty()
130    }
131
132    fn find_fallback_locale_ref(
133        &'s self,
134        _: &LanguageIdentifier,
135    ) -> impl Iterator<Item = &'s LanguageIdentifier> {
136        std::iter::empty()
137    }
138}
139
140// NOTE: rust disallows multiple blanket impls, so unfortunately we need to choose one
141/*
142impl<'s, M> PolyL10nRulebook<'s> for M
143where
144    M: for<'a> std::ops::Index<&'a LanguageIdentifier, Output = LanguageIdentifier>,
145{
146    fn find_fallback_locale(
147        &'s self,
148        locale: &LanguageIdentifier,
149    ) -> impl Iterator<Item = &'s LanguageIdentifier> {
150        std::iter::once(&self[locale])
151    }
152}
153*/
154
155impl<'s, M, LS: 's> PolyL10nRulebook<'s> for M
156where
157    M: for<'a> std::ops::Index<&'a LanguageIdentifier, Output = LS>,
158    &'s LS: IntoIterator<Item = &'s LanguageIdentifier>,
159{
160    fn find_fallback_locale_ref(
161        &'s self,
162        locale: &LanguageIdentifier,
163    ) -> impl Iterator<Item = &'s LanguageIdentifier> {
164        (&self[locale]).into_iter()
165    }
166}
167
168pub type FnRules = Vec<Box<dyn Fn(&LanguageIdentifier) -> Vec<LanguageIdentifier>>>;
169
170/// A set of rules that govern how [`LocaleFallbackSolver`] should handle fallbacks.
171///
172/// For the thread-safe version, see [`ARulebook<A>`].
173///
174/// [`Rulebook<A>`], regardless of type `A`, stores the rules as [`FnRules`], a vector of boxed
175/// `dyn Fn(&LanguageIdentifier) -> Vec<LanguageIdentifier>`. Therefore, the actual correct name of
176/// this struct should be something along the lines of `FnsRulebook`.
177///
178/// Obviously this rulebook can be used with the solver because it implements [`PolyL10nRulebook`].
179///
180/// In addition, the default rulebook [`Rulebook::default()`] can and probably should be used for
181/// most situations you ever need to deal with.
182pub struct Rulebook<A = ()> {
183    pub rules: FnRules,
184    pub owned_values: A,
185}
186
187impl<A: std::fmt::Debug> std::fmt::Debug for Rulebook<A> {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("Rulebook")
190            .field("owned_values", &self.owned_values)
191            .field("rules", &PseudoFnRules::from(&self.rules))
192            .finish_non_exhaustive()
193    }
194}
195/// Used for implementing [`Debug`] for [`Rulebook`].
196struct PseudoFnRules {
197    len: usize,
198}
199impl From<&FnRules> for PseudoFnRules {
200    fn from(value: &FnRules) -> Self {
201        Self { len: value.len() }
202    }
203}
204impl std::fmt::Debug for PseudoFnRules {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        f.debug_struct("FnRules")
207            .field("len", &self.len)
208            .finish_non_exhaustive()
209    }
210}
211
212impl<A> PolyL10nRulebook<'_> for Rulebook<A> {
213    fn find_fallback_locale(
214        &self,
215        locale: &LanguageIdentifier,
216    ) -> impl Iterator<Item = LanguageIdentifier> {
217        self.rules.iter().flat_map(|f| f(locale))
218    }
219}
220
221impl Rulebook<Rc<Vec<Rulebook>>> {
222    /// Combine multiple rulebooks into one.
223    ///
224    /// See also: [`Self::from_ref_rulebooks`].
225    ///
226    /// # Examples
227    /// ```
228    /// let rb1 = poly_l10n::Rulebook::from_fn(|l| {
229    ///   let mut l = l.clone();
230    ///   l.script = None;
231    ///   vec![l]
232    /// });
233    /// let rb2 = poly_l10n::Rulebook::from_fn(|l| {
234    ///   let mut l = l.clone();
235    ///   l.region = None;
236    ///   vec![l]
237    /// });
238    /// let rulebook = poly_l10n::Rulebook::from_rulebooks([rb1, rb2].into_iter());
239    /// let solv = poly_l10n::LocaleFallbackSolver { rulebook };
240    ///
241    /// assert_eq!(
242    ///   solv.solve_locale(poly_l10n::langid!["zh-Hant-HK"]),
243    ///   poly_l10n::langid!["zh-HK", "zh-Hant", "zh"]
244    /// );
245    /// ```
246    pub fn from_rulebooks<I: Iterator<Item = Rulebook>>(rulebooks: I) -> Self {
247        let mut new = Self {
248            owned_values: Rc::new(rulebooks.collect_vec()),
249            rules: vec![],
250        };
251        let owned_values = Rc::clone(&new.owned_values);
252        new.rules = vec![Box::new(move |l: &LanguageIdentifier| {
253            owned_values
254                .iter()
255                .flat_map(|rulebook| rulebook.find_fallback_locale(l).collect_vec())
256                .collect()
257        })];
258        new
259    }
260}
261impl<RR, R> Rulebook<(Rc<Vec<RR>>, std::marker::PhantomData<R>)>
262where
263    RR: AsRef<Rulebook<R>> + 'static,
264{
265    /// Combine multiple rulebooks into one. Each given rulebook `r` must implement
266    /// [`AsRef::as_ref`].
267    ///
268    /// For the owned version, see [`Self::from_rulebooks`].
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// # use std::rc::Rc;
274    /// let rb1 = poly_l10n::Rulebook::from_fn(|l| {
275    ///   let mut l = l.clone();
276    ///   l.script = None;
277    ///   vec![l]
278    /// });
279    /// let rb2 = poly_l10n::Rulebook::from_fn(|l| {
280    ///   let mut l = l.clone();
281    ///   l.region = None;
282    ///   vec![l]
283    /// });
284    /// let (rb1, rb2) = (Rc::new(rb1), Rc::new(rb2));
285    /// let rulebook = poly_l10n::Rulebook::from_ref_rulebooks([rb1, rb2].iter().cloned());
286    /// let solv = poly_l10n::LocaleFallbackSolver { rulebook };
287    ///
288    /// assert_eq!(
289    ///   solv.solve_locale(poly_l10n::langid!["zh-Hant-HK"]),
290    ///   poly_l10n::langid!["zh-HK", "zh-Hant", "zh"]
291    /// );
292    /// ```
293    pub fn from_ref_rulebooks<I: Iterator<Item = RR>>(rulebooks: I) -> Self {
294        let mut new = Self {
295            owned_values: (Rc::new(rulebooks.collect_vec()), std::marker::PhantomData),
296            rules: vec![],
297        };
298        let owned_values = Rc::clone(&new.owned_values.0);
299        new.rules = vec![Box::new(move |l: &LanguageIdentifier| {
300            (owned_values.iter())
301                .flat_map(|rulebook| rulebook.as_ref().find_fallback_locale(l).collect_vec())
302                .collect()
303        })];
304        new
305    }
306}
307
308impl Rulebook {
309    #[must_use]
310    pub fn from_fn<F: Fn(&LanguageIdentifier) -> Vec<LanguageIdentifier> + 'static>(f: F) -> Self {
311        Self {
312            rules: vec![Box::new(f)],
313            owned_values: (),
314        }
315    }
316    #[must_use]
317    pub const fn from_fns(rules: FnRules) -> Self {
318        Self {
319            rules,
320            owned_values: (),
321        }
322    }
323    /// Convert a map (or anything that impl [`std::ops::Index<&LanguageIdentifier>`]) into
324    /// a rulebook.
325    ///
326    /// The output of the map must implement [`IntoIterator<Item = &LanguageIdentifier>`].
327    ///
328    /// While any valid arguments to this constructor are guaranteed to satisfy the trait
329    /// [`PolyL10nRulebook`], it could be useful to convert them to rulebooks, e.g. to combine
330    /// multiple rulebooks using [`Self::from_rulebooks`].
331    pub fn from_map<M, LS>(map: M) -> Self
332    where
333        M: for<'a> std::ops::Index<&'a LanguageIdentifier, Output = LS> + 'static,
334        for<'b> &'b LS: IntoIterator<Item = &'b LanguageIdentifier>,
335    {
336        Self::from_fn(move |l| map[l].into_iter().cloned().collect())
337    }
338}
339
340// TODO: rules?
341impl Default for Rulebook {
342    fn default() -> Self {
343        Self::from_fn(default_rulebook::default_rulebook)
344    }
345}
346
347pub type AFnRules = Vec<Box<dyn Fn(&LanguageIdentifier) -> Vec<LanguageIdentifier> + Send + Sync>>;
348
349/// A set of rules that govern how [`LocaleFallbackSolver`] should handle fallbacks.
350///
351/// This is the thread-safe version of [`Rulebook`].
352///
353/// [`ARulebook<A>`], regardless of type `A`, stores the rules as [`AFnRules`], a vector of boxed
354/// `dyn Fn(&LanguageIdentifier) -> Vec<LanguageIdentifier> + Send + Sync`. Therefore, the actual
355/// correct name of this struct should be something along the lines of `AFnsRulebook`.
356///
357/// Obviously this rulebook can be used with the solver because it implements [`PolyL10nRulebook`].
358///
359/// In addition, the default rulebook [`ARulebook::default()`] can and probably should be used for
360/// most situations you ever need to deal with.
361pub struct ARulebook<A = ()> {
362    pub rules: AFnRules,
363    pub owned_values: A,
364}
365
366impl<A: std::fmt::Debug> std::fmt::Debug for ARulebook<A> {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        f.debug_struct("ARulebook")
369            .field("owned_values", &self.owned_values)
370            .field("rules", &APseudoFnRules::from(&self.rules))
371            .finish_non_exhaustive()
372    }
373}
374/// Used for implementing [`Debug`] for [`ARulebook`].
375struct APseudoFnRules {
376    len: usize,
377}
378impl From<&AFnRules> for APseudoFnRules {
379    fn from(value: &AFnRules) -> Self {
380        Self { len: value.len() }
381    }
382}
383impl std::fmt::Debug for APseudoFnRules {
384    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385        f.debug_struct("AFnRules")
386            .field("len", &self.len)
387            .finish_non_exhaustive()
388    }
389}
390
391impl<A> PolyL10nRulebook<'_> for ARulebook<A> {
392    fn find_fallback_locale(
393        &self,
394        locale: &LanguageIdentifier,
395    ) -> impl Iterator<Item = LanguageIdentifier> {
396        self.rules.iter().flat_map(|f| f(locale))
397    }
398}
399
400impl ARulebook<Arc<Vec<ARulebook>>> {
401    /// Combine multiple rulebooks into one.
402    ///
403    /// See also: [`Self::from_ref_rulebooks`].
404    ///
405    /// # Examples
406    /// ```
407    /// let rb1 = poly_l10n::ARulebook::from_fn(|l| {
408    ///   let mut l = l.clone();
409    ///   l.script = None;
410    ///   vec![l]
411    /// });
412    /// let rb2 = poly_l10n::ARulebook::from_fn(|l| {
413    ///   let mut l = l.clone();
414    ///   l.region = None;
415    ///   vec![l]
416    /// });
417    /// let rulebook = poly_l10n::ARulebook::from_rulebooks([rb1, rb2].into_iter());
418    /// let solv = poly_l10n::LocaleFallbackSolver { rulebook };
419    ///
420    /// assert_eq!(
421    ///   solv.solve_locale(poly_l10n::langid!["zh-Hant-HK"]),
422    ///   poly_l10n::langid!["zh-HK", "zh-Hant", "zh"]
423    /// );
424    /// ```
425    pub fn from_rulebooks<I: Iterator<Item = ARulebook>>(rulebooks: I) -> Self {
426        let mut new = Self {
427            owned_values: Arc::new(rulebooks.collect_vec()),
428            rules: vec![],
429        };
430        let owned_values = Arc::clone(&new.owned_values);
431        new.rules = vec![Box::new(move |l: &LanguageIdentifier| {
432            owned_values
433                .iter()
434                .flat_map(|rulebook| rulebook.find_fallback_locale(l).collect_vec())
435                .collect()
436        })];
437        new
438    }
439}
440impl<RR, R> ARulebook<(Arc<Vec<RR>>, std::marker::PhantomData<R>)>
441where
442    RR: AsRef<ARulebook<R>> + 'static + Send + Sync,
443{
444    /// Combine multiple rulebooks into one. Each given rulebook `r` must implement
445    /// [`AsRef::as_ref`].
446    ///
447    /// For the owned version, see [`Self::from_rulebooks`].
448    ///
449    /// # Examples
450    ///
451    /// ```
452    /// # use std::sync::Arc;
453    /// let rb1 = poly_l10n::ARulebook::from_fn(|l| {
454    ///   let mut l = l.clone();
455    ///   l.script = None;
456    ///   vec![l]
457    /// });
458    /// let rb2 = poly_l10n::ARulebook::from_fn(|l| {
459    ///   let mut l = l.clone();
460    ///   l.region = None;
461    ///   vec![l]
462    /// });
463    /// let (rb1, rb2) = (Arc::new(rb1), Arc::new(rb2));
464    /// let rulebook = poly_l10n::ARulebook::from_ref_rulebooks([rb1, rb2].iter().cloned());
465    /// let solv = poly_l10n::LocaleFallbackSolver { rulebook };
466    ///
467    /// assert_eq!(
468    ///   solv.solve_locale(poly_l10n::langid!["zh-Hant-HK"]),
469    ///   poly_l10n::langid!["zh-HK", "zh-Hant", "zh"]
470    /// );
471    /// ```
472    pub fn from_ref_rulebooks<I: Iterator<Item = RR>>(rulebooks: I) -> Self {
473        let mut new = Self {
474            owned_values: (Arc::new(rulebooks.collect_vec()), std::marker::PhantomData),
475            rules: vec![],
476        };
477        let owned_values = Arc::clone(&new.owned_values.0);
478        new.rules = vec![Box::new(move |l: &LanguageIdentifier| {
479            (owned_values.iter())
480                .flat_map(|rulebook| rulebook.as_ref().find_fallback_locale(l).collect_vec())
481                .collect()
482        })];
483        new
484    }
485}
486
487impl ARulebook {
488    #[must_use]
489    pub fn from_fn<
490        F: Fn(&LanguageIdentifier) -> Vec<LanguageIdentifier> + 'static + Send + Sync,
491    >(
492        f: F,
493    ) -> Self {
494        Self {
495            rules: vec![Box::new(f)],
496            owned_values: (),
497        }
498    }
499    #[must_use]
500    pub const fn from_fns(rules: AFnRules) -> Self {
501        Self {
502            rules,
503            owned_values: (),
504        }
505    }
506    /// Convert a map (or anything that impl [`std::ops::Index<&LanguageIdentifier>`]) into
507    /// a rulebook.
508    ///
509    /// The output of the map must implement [`IntoIterator<Item = &LanguageIdentifier>`].
510    ///
511    /// While any valid arguments to this constructor are guaranteed to satisfy the trait
512    /// [`PolyL10nRulebook`], it could be useful to convert them to rulebooks, e.g. to combine
513    /// multiple rulebooks using [`Self::from_rulebooks`].
514    pub fn from_map<M, LS>(map: M) -> Self
515    where
516        M: for<'a> std::ops::Index<&'a LanguageIdentifier, Output = LS> + 'static + Send + Sync,
517        for<'b> &'b LS: IntoIterator<Item = &'b LanguageIdentifier>,
518    {
519        Self::from_fn(move |l| map[l].into_iter().cloned().collect())
520    }
521}
522
523// TODO: rules?
524impl Default for ARulebook {
525    fn default() -> Self {
526        Self::from_fn(default_rulebook::default_rulebook)
527    }
528}