zng_ext_l10n/
types.rs

1use std::{borrow::Cow, collections::HashMap, fmt, mem, ops, path::PathBuf, sync::Arc};
2
3use fluent::types::FluentNumber;
4use once_cell::sync::Lazy;
5use semver::Version;
6use zng_ext_fs_watcher::WatcherReadStatus;
7use zng_layout::context::LayoutDirection;
8use zng_txt::{ToTxt, Txt};
9use zng_var::{ArcEq, IntoVar, Var, VarValue, const_var, context_var, impl_from_and_into_var};
10
11use crate::{L10N, lang, service::L10N_SV};
12
13/// Handle to multiple localization resources.
14#[derive(Clone, Debug)]
15pub struct LangResources(pub Vec<LangResource>);
16impl ops::Deref for LangResources {
17    type Target = Vec<LangResource>;
18
19    fn deref(&self) -> &Self::Target {
20        &self.0
21    }
22}
23impl ops::DerefMut for LangResources {
24    fn deref_mut(&mut self) -> &mut Self::Target {
25        &mut self.0
26    }
27}
28impl LangResources {
29    /// Wait for all the resources to load.
30    pub async fn wait(&self) {
31        for res in &self.0 {
32            res.wait().await;
33        }
34    }
35
36    /// Drop all handles without dropping the resource.
37    pub fn perm(self) {
38        for res in self.0 {
39            res.perm()
40        }
41    }
42}
43
44/// Handle to a localization resource.
45#[derive(Clone)]
46#[must_use = "resource can unload if dropped"]
47pub struct LangResource {
48    pub(super) res: Var<Option<ArcEq<fluent::FluentResource>>>,
49    pub(super) status: Var<LangResourceStatus>,
50}
51
52impl fmt::Debug for LangResource {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.debug_struct("LangResource")
55            .field("status", &self.status.get())
56            .finish_non_exhaustive()
57    }
58}
59impl LangResource {
60    /// Read-only variable with the resource.
61    pub fn resource(&self) -> &Var<Option<ArcEq<fluent::FluentResource>>> {
62        &self.res
63    }
64
65    /// Read-only variable with the resource status.
66    pub fn status(&self) -> &Var<LangResourceStatus> {
67        &self.status
68    }
69
70    /// Drop the handle without unloading the resource.
71    pub fn perm(self) {
72        L10N_SV.write().push_perm_resource(self);
73    }
74
75    /// Await resource status to not be loading.
76    pub async fn wait(&self) {
77        while matches!(self.status.get(), LangResourceStatus::Loading) {
78            self.status.wait_update().await;
79        }
80    }
81}
82
83/// Status of a localization resource.
84#[derive(Clone, Debug)]
85pub enum LangResourceStatus {
86    /// Resource not available.
87    ///
88    /// This can change if the localization directory changes, or the file is created.
89    NotAvailable,
90    /// Resource is loading.
91    Loading,
92    /// Resource loaded ok.
93    Loaded,
94    /// Resource failed to load.
95    ///
96    /// This can be any IO or parse errors. If the resource if *not found* the status is set to
97    /// `NotAvailable`, not an error. Localization messages fallback on error just like they do
98    /// for not available.
99    Errors(StatusError),
100}
101impl fmt::Display for LangResourceStatus {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            LangResourceStatus::NotAvailable => write!(f, "not available"),
105            LangResourceStatus::Loading => write!(f, "loading…"),
106            LangResourceStatus::Loaded => write!(f, "loaded"),
107            LangResourceStatus::Errors(e) => {
108                writeln!(f, "errors:")?;
109                for e in e {
110                    writeln!(f, "   {e}")?;
111                }
112                Ok(())
113            }
114        }
115    }
116}
117impl PartialEq for LangResourceStatus {
118    fn eq(&self, other: &Self) -> bool {
119        match (self, other) {
120            (Self::Errors(a), Self::Errors(b)) => a.is_empty() && b.is_empty(),
121            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
122        }
123    }
124}
125impl Eq for LangResourceStatus {}
126impl WatcherReadStatus<StatusError> for LangResourceStatus {
127    fn idle() -> Self {
128        Self::Loaded
129    }
130
131    fn reading() -> Self {
132        Self::Loading
133    }
134
135    fn read_error(e: StatusError) -> Self {
136        Self::Errors(e)
137    }
138}
139impl WatcherReadStatus<LangResourceStatus> for LangResourceStatus {
140    fn idle() -> Self {
141        Self::Loaded
142    }
143
144    fn reading() -> Self {
145        Self::Loading
146    }
147
148    fn read_error(e: LangResourceStatus) -> Self {
149        e
150    }
151}
152
153type StatusError = Vec<Arc<dyn std::error::Error + Send + Sync>>;
154
155/// Localized message variable builder.
156///
157/// See [`L10N.message`] for more details.
158///
159/// [`L10N.message`]: L10N::message
160pub struct L10nMessageBuilder {
161    pub(super) file: LangFilePath,
162    pub(super) id: Txt,
163    pub(super) attribute: Txt,
164    pub(super) fallback: Txt,
165    pub(super) args: Vec<(Txt, Var<L10nArgument>)>,
166}
167impl L10nMessageBuilder {
168    /// Add a format arg variable.
169    pub fn arg(mut self, name: Txt, value: impl IntoVar<L10nArgument>) -> Self {
170        self.args.push((name, value.into_var()));
171        self
172    }
173    #[doc(hidden)]
174    pub fn l10n_arg(self, name: &'static str, value: Var<L10nArgument>) -> Self {
175        self.arg(Txt::from_static(name), value)
176    }
177
178    /// Build the message var for the given languages.
179    pub fn build_for(self, lang: impl Into<Langs>) -> Var<Txt> {
180        L10N_SV
181            .write()
182            .localized_message(lang.into(), self.file, self.id, self.attribute, self.fallback, self.args)
183    }
184
185    /// Build the message var for the contextual language.
186    pub fn build(self) -> Var<Txt> {
187        let Self {
188            file,
189            id,
190            attribute,
191            fallback,
192            args,
193        } = self;
194        LANG_VAR.flat_map(move |l| {
195            L10N_SV.write().localized_message(
196                l.clone(),
197                file.clone(),
198                id.clone(),
199                attribute.clone(),
200                fallback.clone(),
201                args.clone(),
202            )
203        })
204    }
205}
206
207/// Represents an argument value for a localization message.
208///
209/// See [`L10nMessageBuilder::arg`] for more details.
210#[derive(Clone, Debug, PartialEq)]
211pub enum L10nArgument {
212    /// String.
213    Txt(Txt),
214    /// Number, with optional style details.
215    Number(FluentNumber),
216}
217impl_from_and_into_var! {
218    fn from(txt: Txt) -> L10nArgument {
219        L10nArgument::Txt(txt)
220    }
221    fn from(txt: &'static str) -> L10nArgument {
222        L10nArgument::Txt(Txt::from_static(txt))
223    }
224    fn from(txt: String) -> L10nArgument {
225        L10nArgument::Txt(Txt::from(txt))
226    }
227    fn from(t: char) -> L10nArgument {
228        L10nArgument::Txt(Txt::from_char(t))
229    }
230    fn from(number: FluentNumber) -> L10nArgument {
231        L10nArgument::Number(number)
232    }
233    fn from(b: bool) -> L10nArgument {
234        b.to_txt().into()
235    }
236}
237macro_rules! impl_from_and_into_var_number {
238    ($($literal:tt),+) => {
239        impl_from_and_into_var! {
240            $(
241                fn from(number: $literal) -> L10nArgument {
242                    FluentNumber::from(number).into()
243                }
244            )+
245        }
246    }
247}
248impl_from_and_into_var_number! { u8, i8, u16, i16, u32, i32, u64, i64, u128, i128, usize, isize, f32, f64 }
249impl L10nArgument {
250    /// Borrow argument as a fluent value.
251    pub fn fluent_value(&self) -> fluent::FluentValue<'_> {
252        match self {
253            L10nArgument::Txt(t) => fluent::FluentValue::String(Cow::Borrowed(t.as_str())),
254            L10nArgument::Number(n) => fluent::FluentValue::Number(n.clone()),
255        }
256    }
257    /// Clone argument as a fluent value.
258    pub fn to_fluent_value(&self) -> fluent::FluentValue<'static> {
259        match self {
260            L10nArgument::Txt(t) => fluent::FluentValue::String(Cow::Owned(t.to_string())),
261            L10nArgument::Number(n) => fluent::FluentValue::Number(n.clone()),
262        }
263    }
264}
265
266#[doc(hidden)]
267pub struct L10nSpecialize<T>(pub Option<T>);
268#[doc(hidden)]
269pub trait IntoL10nVar {
270    fn to_l10n_var(&mut self) -> Var<L10nArgument>;
271}
272
273impl<T: Into<L10nArgument>> IntoL10nVar for L10nSpecialize<T> {
274    fn to_l10n_var(&mut self) -> Var<L10nArgument> {
275        const_var(self.0.take().unwrap().into())
276    }
277}
278impl<T: VarValue + Into<L10nArgument>> IntoL10nVar for &mut L10nSpecialize<Var<T>> {
279    fn to_l10n_var(&mut self) -> Var<L10nArgument> {
280        self.0.take().unwrap().map_into()
281    }
282}
283impl IntoL10nVar for &mut &mut L10nSpecialize<Var<L10nArgument>> {
284    fn to_l10n_var(&mut self) -> Var<L10nArgument> {
285        self.0.take().unwrap()
286    }
287}
288
289context_var! {
290    /// Language of text in a widget context.
291    ///
292    /// Is [`L10N.app_lang`] by default.
293    ///
294    /// [`L10N.app_lang`]: L10N::app_lang
295    pub static LANG_VAR: Langs = L10N.app_lang();
296}
297
298/// Identifies the language, region and script of text.
299///
300/// Use the [`lang!`] macro to construct one, it does compile-time validation.
301///
302/// Use the [`unic_langid`] crate for more advanced operations such as runtime parsing and editing identifiers, this
303/// type is just an alias for the core struct of that crate.
304///
305/// [`unic_langid`]: https://docs.rs/unic-langid
306#[derive(PartialEq, Eq, Hash, Clone, Default, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
307#[serde(transparent)]
308pub struct Lang(pub unic_langid::LanguageIdentifier);
309impl Lang {
310    /// Returns character direction of the language.
311    pub fn direction(&self) -> LayoutDirection {
312        crate::from_unic_char_direction(self.0.character_direction())
313    }
314
315    /// Compares a language to another allowing for either side to use the missing fields as wildcards.
316    ///
317    /// This allows for matching between `en` (treated as `en-*-*-*`) and `en-US`.
318    pub fn matches(&self, other: &Self, self_as_range: bool, other_as_range: bool) -> bool {
319        self.0.matches(&other.0, self_as_range, other_as_range)
320    }
321}
322impl ops::Deref for Lang {
323    type Target = unic_langid::LanguageIdentifier;
324
325    fn deref(&self) -> &Self::Target {
326        &self.0
327    }
328}
329impl fmt::Debug for Lang {
330    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331        write!(f, "{}", self.0)
332    }
333}
334impl fmt::Display for Lang {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        write!(f, "{}", self.0)
337    }
338}
339impl std::str::FromStr for Lang {
340    type Err = unic_langid::LanguageIdentifierError;
341
342    fn from_str(s: &str) -> Result<Self, Self::Err> {
343        let s = s.trim();
344        if s.is_empty() {
345            return Ok(lang!(und));
346        }
347        unic_langid::LanguageIdentifier::from_str(s).map(Lang)
348    }
349}
350
351/// List of languages, in priority order.
352#[derive(Clone, PartialEq, Eq, Default, Hash, serde::Serialize, serde::Deserialize)]
353#[serde(transparent)]
354pub struct Langs(pub Vec<Lang>);
355impl fmt::Debug for Langs {
356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357        struct DisplayLangs<'a>(&'a [Lang]);
358        impl fmt::Debug for DisplayLangs<'_> {
359            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360                f.debug_list().entries(self.0.iter()).finish()
361            }
362        }
363        if f.alternate() {
364            f.debug_tuple("Langs").field(&DisplayLangs(&self.0)).finish()
365        } else {
366            fmt::Debug::fmt(&DisplayLangs(&self.0), f)
367        }
368    }
369}
370impl Langs {
371    /// The first lang on the list or `und` if the list is empty.
372    pub fn best(&self) -> &Lang {
373        static NONE: Lazy<Lang> = Lazy::new(|| lang!(und));
374        self.first().unwrap_or(&NONE)
375    }
376}
377impl ops::Deref for Langs {
378    type Target = Vec<Lang>;
379
380    fn deref(&self) -> &Self::Target {
381        &self.0
382    }
383}
384impl ops::DerefMut for Langs {
385    fn deref_mut(&mut self) -> &mut Self::Target {
386        &mut self.0
387    }
388}
389impl_from_and_into_var! {
390    fn from(lang: Lang) -> Langs {
391        Langs(vec![lang])
392    }
393    fn from(lang: Option<Lang>) -> Langs {
394        Langs(lang.into_iter().collect())
395    }
396}
397impl fmt::Display for Langs {
398    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399        let mut sep = "";
400        for l in self.iter() {
401            write!(f, "{sep}{l}")?;
402            sep = ", ";
403        }
404        Ok(())
405    }
406}
407impl std::str::FromStr for Langs {
408    type Err = unic_langid::LanguageIdentifierError;
409
410    fn from_str(s: &str) -> Result<Self, Self::Err> {
411        if s.trim().is_empty() {
412            return Ok(Langs(vec![]));
413        }
414        let mut r = Self(vec![]);
415        for lang in s.split(',') {
416            r.0.push(lang.trim().parse()?)
417        }
418        Ok(r)
419    }
420}
421
422/// Represents a map of [`Lang`] keys that can be partially matched.
423#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
424#[serde(transparent)]
425pub struct LangMap<V> {
426    inner: Vec<(Lang, V)>,
427}
428impl<V> Default for LangMap<V> {
429    fn default() -> Self {
430        Self { inner: Default::default() }
431    }
432}
433impl<V> LangMap<V> {
434    /// New empty default.
435    pub fn new() -> Self {
436        LangMap::default()
437    }
438
439    /// New empty with pre-allocated capacity.
440    pub fn with_capacity(capacity: usize) -> Self {
441        LangMap {
442            inner: Vec::with_capacity(capacity),
443        }
444    }
445
446    fn exact_i(&self, lang: &Lang) -> Option<usize> {
447        for (i, (key, _)) in self.inner.iter().enumerate() {
448            if key == lang {
449                return Some(i);
450            }
451        }
452        None
453    }
454
455    fn best_i(&self, lang: &Lang) -> Option<usize> {
456        let mut best = None;
457        let mut best_weight = 0;
458
459        for (i, (key, _)) in self.inner.iter().enumerate() {
460            if lang.matches(key, true, true) {
461                let mut weight = 1;
462                let mut eq = 0;
463
464                if key.language == lang.language {
465                    weight += 128;
466                    eq += 1;
467                }
468                if key.region == lang.region {
469                    weight += 40;
470                    eq += 1;
471                }
472                if key.script == lang.script {
473                    weight += 20;
474                    eq += 1;
475                }
476
477                if eq == 3 && lang.variants().zip(key.variants()).all(|(a, b)| a == b) {
478                    return Some(i);
479                }
480
481                if best_weight < weight {
482                    best_weight = weight;
483                    best = Some(i);
484                }
485            }
486        }
487
488        best
489    }
490
491    /// Returns the best match to `lang` currently in the map.
492    pub fn best_match(&self, lang: &Lang) -> Option<&Lang> {
493        if let Some(i) = self.best_i(lang) {
494            Some(&self.inner[i].0)
495        } else {
496            None
497        }
498    }
499
500    /// Returns the best match for `lang`.
501    pub fn get(&self, lang: &Lang) -> Option<&V> {
502        if let Some(i) = self.best_i(lang) {
503            Some(&self.inner[i].1)
504        } else {
505            None
506        }
507    }
508
509    /// Returns the exact match for `lang`.
510    pub fn get_exact(&self, lang: &Lang) -> Option<&V> {
511        if let Some(i) = self.exact_i(lang) {
512            Some(&self.inner[i].1)
513        } else {
514            None
515        }
516    }
517
518    /// Returns the best match for `lang`.
519    pub fn get_mut(&mut self, lang: &Lang) -> Option<&mut V> {
520        if let Some(i) = self.best_i(lang) {
521            Some(&mut self.inner[i].1)
522        } else {
523            None
524        }
525    }
526
527    /// Returns the exact match for `lang`.
528    pub fn get_exact_mut(&mut self, lang: &Lang) -> Option<&mut V> {
529        if let Some(i) = self.exact_i(lang) {
530            Some(&mut self.inner[i].1)
531        } else {
532            None
533        }
534    }
535
536    /// Returns the current value or insert `new` and return a reference to it.
537    pub fn get_exact_or_insert(&mut self, lang: Lang, new: impl FnOnce() -> V) -> &mut V {
538        if let Some(i) = self.exact_i(&lang) {
539            return &mut self.inner[i].1;
540        }
541        let i = self.inner.len();
542        self.inner.push((lang, new()));
543        &mut self.inner[i].1
544    }
545
546    /// Insert the value with the exact match of `lang`.
547    ///
548    /// Returns the previous exact match.
549    pub fn insert(&mut self, lang: Lang, value: V) -> Option<V> {
550        if let Some(i) = self.exact_i(&lang) {
551            Some(mem::replace(&mut self.inner[i].1, value))
552        } else {
553            self.inner.push((lang, value));
554            None
555        }
556    }
557
558    /// Remove the exact match of `lang`.
559    pub fn remove(&mut self, lang: &Lang) -> Option<V> {
560        if let Some(i) = self.exact_i(lang) {
561            Some(self.inner.swap_remove(i).1)
562        } else {
563            None
564        }
565    }
566
567    /// Remove all exact and partial matches of `lang`.
568    ///
569    /// Returns a count of items removed.
570    pub fn remove_all(&mut self, lang: &Lang) -> usize {
571        let mut count = 0;
572        self.inner.retain(|(key, _)| {
573            let rmv = lang.matches(key, true, false);
574            if rmv {
575                count += 1
576            }
577            !rmv
578        });
579        count
580    }
581
582    /// Remove the last inserted lang.
583    pub fn pop(&mut self) -> Option<(Lang, V)> {
584        self.inner.pop()
585    }
586
587    /// Returns if the map is empty.
588    pub fn is_empty(&self) -> bool {
589        self.inner.is_empty()
590    }
591
592    /// Returns the number of languages in the map.
593    pub fn len(&self) -> usize {
594        self.inner.len()
595    }
596
597    /// Remove all entries.
598    pub fn clear(&mut self) {
599        self.inner.clear()
600    }
601
602    /// Iterator over lang keys.
603    pub fn keys(&self) -> impl std::iter::ExactSizeIterator<Item = &Lang> {
604        self.inner.iter().map(|(k, _)| k)
605    }
606
607    /// Iterator over values.
608    pub fn values(&self) -> impl std::iter::ExactSizeIterator<Item = &V> {
609        self.inner.iter().map(|(_, v)| v)
610    }
611
612    /// Iterator over values.
613    pub fn values_mut(&mut self) -> impl std::iter::ExactSizeIterator<Item = &mut V> {
614        self.inner.iter_mut().map(|(_, v)| v)
615    }
616
617    /// Into iterator of values.
618    pub fn into_values(self) -> impl std::iter::ExactSizeIterator<Item = V> {
619        self.inner.into_iter().map(|(_, v)| v)
620    }
621
622    /// Iterate over key-value pairs.
623    pub fn iter(&self) -> impl std::iter::ExactSizeIterator<Item = (&Lang, &V)> {
624        self.inner.iter().map(|(k, v)| (k, v))
625    }
626
627    /// Iterate over key-value pairs with mutable values.
628    pub fn iter_mut(&mut self) -> impl std::iter::ExactSizeIterator<Item = (&Lang, &mut V)> {
629        self.inner.iter_mut().map(|(k, v)| (&*k, v))
630    }
631}
632impl<V> LangMap<HashMap<LangFilePath, V>> {
633    /// Returns the match for `lang` and `file`.
634    pub fn get_file(&self, lang: &Lang, file: &LangFilePath) -> Option<&V> {
635        let files = self.get(lang)?;
636        if let Some(exact) = files.get(file) {
637            return Some(exact);
638        }
639        Self::best_file(files, file).map(|(_, v)| v)
640    }
641
642    /// Returns the best match to `lang` and `file` currently in the map.
643    pub fn best_file_match(&self, lang: &Lang, file: &LangFilePath) -> Option<&LangFilePath> {
644        let files = self.get(lang)?;
645        if let Some((exact, _)) = files.get_key_value(file) {
646            return Some(exact);
647        }
648        Self::best_file(files, file).map(|(k, _)| k)
649    }
650
651    fn best_file<'a>(files: &'a HashMap<LangFilePath, V>, file: &LangFilePath) -> Option<(&'a LangFilePath, &'a V)> {
652        let mut best = None;
653        let mut best_dist = u64::MAX;
654        for (k, v) in files {
655            if let Some(d) = k.matches(file)
656                && d < best_dist
657            {
658                best = Some((k, v));
659                best_dist = d;
660            }
661        }
662        best
663    }
664}
665impl<V> IntoIterator for LangMap<V> {
666    type Item = (Lang, V);
667
668    type IntoIter = std::vec::IntoIter<(Lang, V)>;
669
670    fn into_iter(self) -> Self::IntoIter {
671        self.inner.into_iter()
672    }
673}
674impl<V: PartialEq> PartialEq for LangMap<V> {
675    fn eq(&self, other: &Self) -> bool {
676        if self.len() != other.len() {
677            return false;
678        }
679        for (k, v) in &self.inner {
680            if other.get_exact(k) != Some(v) {
681                return false;
682            }
683        }
684        true
685    }
686}
687impl<V: Eq> Eq for LangMap<V> {}
688
689/// Errors found parsing a fluent resource file.
690#[derive(Clone, Debug)]
691pub struct FluentParserErrors(pub Vec<fluent_syntax::parser::ParserError>);
692impl fmt::Display for FluentParserErrors {
693    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
694        let mut sep = "";
695        for e in &self.0 {
696            write!(f, "{sep}{e}")?;
697            sep = "\n";
698        }
699        Ok(())
700    }
701}
702impl std::error::Error for FluentParserErrors {
703    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
704        if self.0.len() == 1 { Some(&self.0[0]) } else { None }
705    }
706}
707
708/// Localization resource file path in the localization directory.
709///
710/// In the default directory layout, localization dependencies are collected using `cargo zng l10n`
711/// and copied to `l10n/{lang}/deps/{name}/{version}/`, and localization for the app ([`is_current_app`])
712/// is placed in `l10n/{lang}/`.
713///
714/// [`is_current_app`]: Self::is_current_app
715#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
716#[non_exhaustive]
717pub struct LangFilePath {
718    /// Package name.
719    pub pkg_name: Txt,
720    /// Package version.
721    pub pkg_version: Version,
722    /// The localization file name, without extension.
723    pub file: Txt,
724}
725impl Ord for LangFilePath {
726    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
727        let self_pkg = self.actual_pkg_data();
728        let other_pkg = other.actual_pkg_data();
729        match self_pkg.0.cmp(other_pkg.0) {
730            core::cmp::Ordering::Equal => {}
731            ord => return ord,
732        }
733        match self_pkg.1.cmp(other_pkg.1) {
734            core::cmp::Ordering::Equal => {}
735            ord => return ord,
736        }
737        self.file().cmp(&other.file())
738    }
739}
740impl PartialOrd for LangFilePath {
741    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
742        Some(self.cmp(other))
743    }
744}
745impl std::hash::Hash for LangFilePath {
746    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
747        self.actual_pkg_data().hash(state);
748        self.file().hash(state);
749    }
750}
751impl Eq for LangFilePath {}
752impl PartialEq for LangFilePath {
753    fn eq(&self, other: &Self) -> bool {
754        self.actual_pkg_data() == other.actual_pkg_data() && self.file() == other.file()
755    }
756}
757impl LangFilePath {
758    /// New from package name, version and file.
759    pub fn new(pkg_name: impl Into<Txt>, pkg_version: Version, file: impl Into<Txt>) -> Self {
760        let r = Self {
761            pkg_name: pkg_name.into(),
762            pkg_version,
763            file: file.into(),
764        };
765        // these non-standard names are matched on fallback, but they can cause duplicate caching
766        debug_assert!(
767            r.file
768                .rsplit_once('.')
769                .map(|(_, ext)| !ext.eq_ignore_ascii_case("ftl"))
770                .unwrap_or(true),
771            "file `{}` must not have extension",
772            r.file
773        );
774        debug_assert!(r.file != "_", "file `_` should be an empty string");
775        r
776    }
777
778    /// Gets a file in the current app.
779    ///
780    /// This value indicates that the localization resources are directly on the `l10n/{lang?}/` directories, not
781    /// in the dependencies directories.
782    ///
783    /// See [`zng_env::about()`] for more details.
784    pub fn current_app(file: impl Into<Txt>) -> LangFilePath {
785        let about = zng_env::about();
786        Self::new(about.pkg_name.clone(), about.version.clone(), file.into())
787    }
788
789    /// Gets if this file is in the [`current_app`] resources, or the `pkg_name` is empty or the `pkg_version.pre` is `#.#.#-local`.
790    ///
791    /// [`current_app`]: Self::current_app
792    pub fn is_current_app(&self) -> bool {
793        self.is_current_app_no_check() || {
794            let about = zng_env::about();
795            self.pkg_name == about.pkg_name && self.pkg_version == about.version
796        }
797    }
798
799    fn is_current_app_no_check(&self) -> bool {
800        self.pkg_name.is_empty() || self.pkg_version.pre.as_str() == "local"
801    }
802
803    fn actual_pkg_data(&self) -> (&Txt, &Version) {
804        if self.is_current_app_no_check() {
805            let about = zng_env::about();
806            (&about.pkg_name, &about.version)
807        } else {
808            (&self.pkg_name, &self.pkg_version)
809        }
810    }
811
812    /// Gets the normalized package name.
813    ///
814    /// This is the app package name if [`is_current_app`], otherwise is just the `pkg_name` value.
815    ///
816    /// [`is_current_app`]: Self::is_current_app
817    pub fn pkg_name(&self) -> Txt {
818        self.actual_pkg_data().0.clone()
819    }
820
821    /// Gets the normalized package version.
822    ///
823    /// This is the app version if [`is_current_app`], otherwise is just the `pkg_version` value.
824    ///
825    /// [`is_current_app`]: Self::is_current_app
826    pub fn pkg_version(&self) -> Version {
827        self.actual_pkg_data().1.clone()
828    }
829
830    /// Gets the normalized file name.
831    ///
832    /// This `"_"` for empty file or the file.
833    pub fn file(&self) -> Txt {
834        if self.file.is_empty() {
835            Txt::from_char('_')
836        } else {
837            self.file.clone()
838        }
839    }
840
841    /// Get the file path, relative to the localization dir.
842    ///
843    /// * Empty file name is the same as `_`.
844    /// * If package [`is_current_app`] gets `{lang}/{file}.ftl`.
845    /// * Else if is another package gets `{lang}/deps/{pkg_name}/{pkg_version}/{file}.ftl`.
846    ///
847    /// [`is_current_app`]: Self::is_current_app
848    pub fn to_path(&self, lang: &Lang) -> PathBuf {
849        let mut file = self.file.as_str();
850        if file.is_empty() {
851            file = "_";
852        }
853        if self.is_current_app() {
854            format!("{lang}/{file}.ftl")
855        } else {
856            format!("{lang}/deps/{}/{}/{file}.ftl", self.pkg_name, self.pkg_version)
857        }
858        .into()
859    }
860
861    /// Gets a value that indicates if the resources represented by `self` can be used for `search`.
862    ///
863    /// The number indicates the quality of the match:
864    ///
865    /// * `0` is an exact match.
866    /// * `b1` is a match with only version `build` differences.
867    /// * `b10` is a match with only version `pre` differences.
868    /// * `(0..u16::MAX) << 16` is a match with only `patch` differences and the absolute distance.
869    /// * `(0..u16::MAX) << 16 * 2` is a match with `minor` differences and the absolute distance.
870    /// * `(0..u16::MAX) << 16 * 3` is a match with `major` differences and the absolute distance.
871    /// * `None`` is a `pkg_name` mismatch.
872    pub fn matches(&self, search: &Self) -> Option<u64> {
873        let (self_name, self_version) = self.actual_pkg_data();
874        let (search_name, search_version) = search.actual_pkg_data();
875
876        if self_name != search_name {
877            return None;
878        }
879
880        if self.file != search.file {
881            let file_a = self.file.rsplit_once('.').map(|t| t.0).unwrap_or(self.file.as_str());
882            let file_b = search.file.rsplit_once('.').map(|t| t.0).unwrap_or(search.file.as_str());
883            if file_a != file_b {
884                let is_empty_a = file_a == "_" || file_a.is_empty();
885                let is_empty_b = file_b == "_" || file_b.is_empty();
886                if !(is_empty_a && is_empty_b) {
887                    return None;
888                }
889            }
890            tracing::warn!(
891                "fallback matching `{}` with `{}`, file was not expected to have extension",
892                self.file,
893                search.file
894            )
895        }
896
897        fn dist(a: u64, b: u64, shift: u64) -> u64 {
898            let (l, s) = match a.cmp(&b) {
899                std::cmp::Ordering::Equal => return 0,
900                std::cmp::Ordering::Less => (b, a),
901                std::cmp::Ordering::Greater => (a, b),
902            };
903
904            (l - s).min(u16::MAX as u64) << (16 * shift)
905        }
906
907        let mut d = 0;
908        if self_version.build != search_version.build {
909            d = 1;
910        }
911        if self_version.pre != search_version.pre {
912            d |= 0b10;
913        }
914
915        d |= dist(self_version.patch, search_version.patch, 1);
916        d |= dist(self_version.minor, search_version.minor, 2);
917        d |= dist(self_version.major, search_version.major, 3);
918
919        Some(d)
920    }
921}
922impl_from_and_into_var! {
923    fn from(file: Txt) -> LangFilePath {
924        LangFilePath::current_app(file)
925    }
926
927    fn from(file: &'static str) -> LangFilePath {
928        LangFilePath::current_app(file)
929    }
930
931    fn from(file: String) -> LangFilePath {
932        LangFilePath::current_app(file)
933    }
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    #[test]
941    fn file_matches() {
942        fn check(a: &str, b: &str, c: &str) {
943            let ap = LangFilePath::new("name", a.parse().unwrap(), "file");
944            let bp = LangFilePath::new("name", b.parse().unwrap(), "file");
945            let cp = LangFilePath::new("name", c.parse().unwrap(), "file");
946
947            let ab = ap.matches(&bp);
948            let ac = ap.matches(&cp);
949
950            assert!(ab < ac, "expected {a}.matches({b}) < {a}.matches({c})")
951        }
952
953        check("0.0.0", "0.0.1", "0.1.0");
954        check("0.0.1", "0.1.0", "1.0.0");
955        check("0.0.0-pre", "0.0.0-pre+build", "0.0.0-other+build");
956        check("0.0.0+build", "0.0.0+build", "0.0.0+other");
957        check("0.0.1", "0.0.2", "0.0.3");
958        check("0.1.0", "0.2.0", "0.3.0");
959        check("1.0.0", "2.0.0", "3.0.0");
960        check("1.0.0", "1.1.0", "2.0.0");
961    }
962
963    #[test]
964    fn file_name_mismatches() {
965        let ap = LangFilePath::new("name", "1.0.0".parse().unwrap(), "file-a");
966        let bp = LangFilePath::new("name", "1.0.0".parse().unwrap(), "file-b");
967
968        assert!(ap.matches(&bp).is_none());
969    }
970}