Skip to main content

es_fluent/traits/
fluent_message.rs

1use crate::FluentValue;
2use crate::registry::{
3    StaticFluentArgumentName, StaticFluentDomain, StaticFluentEntryId, StaticFluentVariantKey,
4};
5use es_fluent_manager_core::FluentManager;
6use std::sync::Arc;
7
8const WITH_LOOKUP_CALLBACK_COUNT_ERROR: &str =
9    "FluentLocalizer::with_lookup must invoke its callback exactly once";
10
11/// Generated Fluent arguments keyed by validated static argument names.
12#[derive(Clone, Debug, Default)]
13pub struct FluentArgs<'a> {
14    values: es_fluent_manager_core::FluentArgumentMap<'a>,
15}
16
17impl<'a> FluentArgs<'a> {
18    pub fn new() -> Self {
19        Self {
20            values: es_fluent_manager_core::FluentArgumentMap::default(),
21        }
22    }
23
24    pub fn insert(&mut self, name: StaticFluentArgumentName, value: FluentValue<'a>) {
25        self.values.insert(name, value);
26    }
27
28    pub fn as_raw(&self) -> &es_fluent_manager_core::FluentArgumentMap<'a> {
29        &self.values
30    }
31
32    pub fn is_empty(&self) -> bool {
33        self.values.is_empty()
34    }
35}
36
37/// Render-time lookup callback used by [`FluentMessage`] implementations.
38pub type FluentMessageLookup<'lookup> = dyn for<'a> FnMut(StaticFluentDomain, StaticFluentEntryId, Option<&'a FluentArgs<'a>>) -> String
39    + 'lookup;
40
41/// Fallible render-time lookup callback supplied by [`FluentLocalizer`].
42pub type FluentLocalizerLookup<'lookup> = dyn for<'a> FnMut(
43        StaticFluentDomain,
44        StaticFluentEntryId,
45        Option<&'a FluentArgs<'a>>,
46    ) -> Option<String>
47    + 'lookup;
48
49/// A typed Fluent message that can be resolved by an explicit localization
50/// backend.
51///
52/// Derive macros implement this trait for `#[derive(EsFluent)]` and generated
53/// variant enums. Runtime managers use it to keep typed message call sites while
54/// routing lookup through a request, component, or application-scoped manager.
55pub trait FluentMessage {
56    /// Converts the message into a localized string using the supplied lookup
57    /// callback.
58    ///
59    /// Manual implementations should treat `localize` as the only lookup path
60    /// during rendering. Do not re-enter the same localizer to select a
61    /// language or perform other lock-taking lookups from this method; managers
62    /// may hold snapshot locks while invoking it.
63    fn to_fluent_string_with(&self, localize: &mut FluentMessageLookup<'_>) -> String;
64}
65
66impl<T: FluentMessage + ?Sized> FluentMessage for &T {
67    fn to_fluent_string_with(&self, localize: &mut FluentMessageLookup<'_>) -> String {
68        (**self).to_fluent_string_with(localize)
69    }
70}
71
72/// Runtime context that resolves Fluent message IDs for typed message values.
73///
74/// Managers and framework adapters implement this trait so callers can pass
75/// the active localization context explicitly.
76///
77/// # Implementing `FluentLocalizer`
78///
79/// Custom localizers should either use the default [`Self::with_lookup`]
80/// implementation or override it to provide one render-scoped snapshot. If
81/// `with_lookup(...)` is overridden, it must invoke the callback exactly once
82/// before returning. Failing to do so is a logic error and will panic in
83/// [`FluentLocalizerExt::localize_message`] and
84/// [`FluentLocalizerExt::try_localize_message`].
85pub trait FluentLocalizer {
86    /// Localizes a validated static message ID using the localizer's default
87    /// lookup behavior.
88    fn localize<'a>(
89        &self,
90        id: StaticFluentEntryId,
91        args: Option<&'a FluentArgs<'a>>,
92    ) -> Option<String>;
93
94    /// Localizes a validated static message ID within a validated static
95    /// domain.
96    fn localize_in_domain<'a>(
97        &self,
98        domain: StaticFluentDomain,
99        id: StaticFluentEntryId,
100        args: Option<&'a FluentArgs<'a>>,
101    ) -> Option<String>;
102
103    /// Runs a group of lookups against one render-scoped localization view.
104    ///
105    /// Implementations must invoke the callback exactly once, must not call it
106    /// after `with_lookup(...)` returns, and should provide a stable lookup
107    /// snapshot for the duration of that callback. The extension methods rely
108    /// on this contract when rendering nested typed messages.
109    ///
110    /// The callback is the only supported lookup path inside a typed message
111    /// render. Custom `FluentMessage` implementations must not re-enter the
112    /// same localizer for language selection or other lock-taking operations
113    /// while this callback is active.
114    ///
115    /// The default implementation delegates each lookup independently. Managers
116    /// with mutable language selection should override this to hold the relevant
117    /// lock or snapshot for the whole callback.
118    ///
119    /// # Example
120    ///
121    /// ```
122    /// # use es_fluent::{FluentArgs, FluentLocalizer};
123    /// # use es_fluent::registry::{StaticFluentDomain, StaticFluentEntryId};
124    /// struct MyLocalizer;
125    ///
126    /// fn lookup(
127    ///     domain: StaticFluentDomain,
128    ///     id: StaticFluentEntryId,
129    ///     _args: Option<&FluentArgs<'_>>,
130    /// ) -> Option<String> {
131    ///     Some(format!("{domain}:{id}"))
132    /// }
133    ///
134    /// impl FluentLocalizer for MyLocalizer {
135    ///     fn localize<'a>(
136    ///         &self,
137    ///         id: StaticFluentEntryId,
138    ///         args: Option<&FluentArgs<'a>>,
139    ///     ) -> Option<String> {
140    ///         self.localize_in_domain(StaticFluentDomain::from_package_name(env!("CARGO_PKG_NAME")), id, args)
141    ///     }
142    ///
143    ///     fn localize_in_domain<'a>(
144    ///         &self,
145    ///         domain: StaticFluentDomain,
146    ///         id: StaticFluentEntryId,
147    ///         args: Option<&FluentArgs<'a>>,
148    ///     ) -> Option<String> {
149    ///         lookup(domain, id, args)
150    ///     }
151    ///
152    ///     fn with_lookup(
153    ///         &self,
154    ///         f: &mut dyn FnMut(&mut es_fluent::FluentLocalizerLookup<'_>),
155    ///     ) {
156    ///         let mut lookup = lookup;
157    ///         f(&mut lookup);
158    ///     }
159    /// }
160    /// ```
161    fn with_lookup(&self, f: &mut dyn FnMut(&mut FluentLocalizerLookup<'_>)) {
162        let mut lookup =
163            |domain: StaticFluentDomain, id: StaticFluentEntryId, args: Option<&FluentArgs<'_>>| {
164                self.localize_in_domain(domain, id, args)
165            };
166        f(&mut lookup);
167    }
168}
169
170impl FluentLocalizer for FluentManager {
171    fn localize<'a>(
172        &self,
173        id: StaticFluentEntryId,
174        args: Option<&FluentArgs<'a>>,
175    ) -> Option<String> {
176        FluentManager::localize(self, id, args.map(FluentArgs::as_raw))
177    }
178
179    fn localize_in_domain<'a>(
180        &self,
181        domain: StaticFluentDomain,
182        id: StaticFluentEntryId,
183        args: Option<&FluentArgs<'a>>,
184    ) -> Option<String> {
185        FluentManager::localize_in_domain(self, domain, id, args.map(FluentArgs::as_raw))
186    }
187
188    fn with_lookup(&self, f: &mut dyn FnMut(&mut FluentLocalizerLookup<'_>)) {
189        FluentManager::with_lookup(self, &mut |lookup| {
190            let mut typed_lookup =
191                |domain: StaticFluentDomain,
192                 id: StaticFluentEntryId,
193                 args: Option<&FluentArgs<'_>>| {
194                    lookup(domain, id, args.map(FluentArgs::as_raw))
195                };
196            f(&mut typed_lookup);
197        });
198    }
199}
200
201impl<T: FluentLocalizer + ?Sized> FluentLocalizer for &T {
202    fn localize<'a>(
203        &self,
204        id: StaticFluentEntryId,
205        args: Option<&FluentArgs<'a>>,
206    ) -> Option<String> {
207        (**self).localize(id, args)
208    }
209
210    fn localize_in_domain<'a>(
211        &self,
212        domain: StaticFluentDomain,
213        id: StaticFluentEntryId,
214        args: Option<&FluentArgs<'a>>,
215    ) -> Option<String> {
216        (**self).localize_in_domain(domain, id, args)
217    }
218
219    fn with_lookup(&self, f: &mut dyn FnMut(&mut FluentLocalizerLookup<'_>)) {
220        (**self).with_lookup(f);
221    }
222}
223
224impl<T: FluentLocalizer + ?Sized> FluentLocalizer for Arc<T> {
225    fn localize<'a>(
226        &self,
227        id: StaticFluentEntryId,
228        args: Option<&FluentArgs<'a>>,
229    ) -> Option<String> {
230        (**self).localize(id, args)
231    }
232
233    fn localize_in_domain<'a>(
234        &self,
235        domain: StaticFluentDomain,
236        id: StaticFluentEntryId,
237        args: Option<&FluentArgs<'a>>,
238    ) -> Option<String> {
239        (**self).localize_in_domain(domain, id, args)
240    }
241
242    fn with_lookup(&self, f: &mut dyn FnMut(&mut FluentLocalizerLookup<'_>)) {
243        (**self).with_lookup(f);
244    }
245}
246
247/// Public extension methods for generic explicit localization contexts.
248///
249/// Concrete manager crates expose inherent `localize_message(...)` methods for
250/// application code. Import this trait when integration code works with a
251/// generic [`FluentLocalizer`] and still needs typed message rendering.
252pub trait FluentLocalizerExt: FluentLocalizer {
253    /// Attempts to render a derived typed message through this explicit
254    /// localizer.
255    ///
256    /// Returns `None` if any lookup in the message tree is missing. Use
257    /// `localize_message(...)` when a message ID fallback is
258    /// desired instead.
259    fn try_localize_message<T>(&self, message: &T) -> Option<String>
260    where
261        T: FluentMessage + ?Sized,
262    {
263        let mut missing = false;
264        let mut value = None;
265        let mut callback_invocations = 0;
266
267        self.with_lookup(&mut |lookup| {
268            assert!(
269                callback_invocations == 0,
270                "{}",
271                WITH_LOOKUP_CALLBACK_COUNT_ERROR
272            );
273            callback_invocations = 1;
274
275            value = Some(message.to_fluent_string_with(&mut |domain, id, args| {
276                lookup(domain, id, args).unwrap_or_else(|| {
277                    missing = true;
278                    String::new()
279                })
280            }));
281        });
282
283        assert!(
284            callback_invocations == 1,
285            "{}",
286            WITH_LOOKUP_CALLBACK_COUNT_ERROR
287        );
288        let value = value.expect(WITH_LOOKUP_CALLBACK_COUNT_ERROR);
289        if missing { None } else { Some(value) }
290    }
291
292    /// Renders a derived typed message through this explicit localizer.
293    fn localize_message<T>(&self, message: &T) -> String
294    where
295        T: FluentMessage + ?Sized,
296    {
297        let mut value = None;
298        let mut callback_invocations = 0;
299
300        self.with_lookup(&mut |lookup| {
301            assert!(
302                callback_invocations == 0,
303                "{}",
304                WITH_LOOKUP_CALLBACK_COUNT_ERROR
305            );
306            callback_invocations = 1;
307
308            value = Some(message.to_fluent_string_with(&mut |domain, id, args| {
309                lookup(domain, id, args).unwrap_or_else(|| {
310                    tracing::warn!(
311                        domain = domain.as_str(),
312                        message_id = id.as_str(),
313                        "missing Fluent message"
314                    );
315                    id.as_str().to_string()
316                })
317            }));
318        });
319
320        assert!(
321            callback_invocations == 1,
322            "{}",
323            WITH_LOOKUP_CALLBACK_COUNT_ERROR
324        );
325        value.expect(WITH_LOOKUP_CALLBACK_COUNT_ERROR)
326    }
327}
328
329impl<T: FluentLocalizer + ?Sized> FluentLocalizerExt for T {}
330
331#[doc(hidden)]
332pub trait IntoFluentValue<'a> {
333    fn into_fluent_value(self) -> FluentValue<'a>;
334}
335
336impl<'a, T> IntoFluentValue<'a> for T
337where
338    T: Into<FluentValue<'a>>,
339{
340    fn into_fluent_value(self) -> FluentValue<'a> {
341        self.into()
342    }
343}
344
345/// Wrapper used by generated `FluentMessage` implementations to keep nested
346/// localized arguments on the same explicit lookup path as the outer message.
347#[doc(hidden)]
348pub struct FluentArgumentValue<T> {
349    value: T,
350}
351
352impl<T> FluentArgumentValue<T> {
353    pub fn new(value: T) -> Self {
354        Self { value }
355    }
356}
357
358/// Borrowed wrapper used by generated `FluentMessage` implementations for
359/// ordinary fields. Nested messages are rendered through the current callback;
360/// scalar values are cloned only at the final conversion boundary.
361#[doc(hidden)]
362pub struct FluentBorrowedArgumentValue<'a, T: ?Sized> {
363    value: &'a T,
364}
365
366impl<'a, T: ?Sized> FluentBorrowedArgumentValue<'a, T> {
367    pub fn new(value: &'a T) -> Self {
368        Self { value }
369    }
370}
371
372/// Optional wrapper used by generated `FluentMessage` implementations so
373/// `Option<T>` can represent missing Fluent arguments without losing nested
374/// message localization for `Some(T)`.
375#[doc(hidden)]
376pub struct FluentOptionalArgumentValue<T> {
377    value: Option<T>,
378}
379
380impl<T> FluentOptionalArgumentValue<T> {
381    pub fn new(value: Option<T>) -> Self {
382        Self { value }
383    }
384}
385
386/// Converts generated message arguments into Fluent values.
387///
388/// This intentionally uses autoref-priority implementations: exact
389/// `FluentArgumentValue<T>` dispatch is selected for nested `FluentMessage`
390/// values, while ordinary argument values fall back to `Into<FluentValue>` via
391/// `&FluentArgumentValue<T>`.
392#[doc(hidden)]
393pub trait IntoFluentArgumentValue<'a> {
394    fn into_fluent_argument_value(self, localize: &mut FluentMessageLookup<'_>) -> FluentValue<'a>;
395}
396
397impl<'a, T> IntoFluentArgumentValue<'a> for FluentArgumentValue<T>
398where
399    T: FluentMessage,
400{
401    fn into_fluent_argument_value(self, localize: &mut FluentMessageLookup<'_>) -> FluentValue<'a> {
402        self.value.to_fluent_string_with(localize).into()
403    }
404}
405
406impl<'a, 'value, T> IntoFluentArgumentValue<'a> for FluentBorrowedArgumentValue<'value, T>
407where
408    T: FluentMessage + ?Sized,
409{
410    fn into_fluent_argument_value(self, localize: &mut FluentMessageLookup<'_>) -> FluentValue<'a> {
411        self.value.to_fluent_string_with(localize).into()
412    }
413}
414
415impl<'a, T> IntoFluentArgumentValue<'a> for &FluentArgumentValue<T>
416where
417    T: Clone + IntoFluentValue<'a>,
418{
419    fn into_fluent_argument_value(
420        self,
421        _localize: &mut FluentMessageLookup<'_>,
422    ) -> FluentValue<'a> {
423        self.value.clone().into_fluent_value()
424    }
425}
426
427impl<'a, 'value, T> IntoFluentArgumentValue<'a> for &FluentBorrowedArgumentValue<'value, T>
428where
429    T: Clone + IntoFluentValue<'a>,
430{
431    fn into_fluent_argument_value(
432        self,
433        _localize: &mut FluentMessageLookup<'_>,
434    ) -> FluentValue<'a> {
435        (*self.value).clone().into_fluent_value()
436    }
437}
438
439impl<'a> IntoFluentArgumentValue<'a> for FluentArgumentValue<bool> {
440    fn into_fluent_argument_value(
441        self,
442        _localize: &mut FluentMessageLookup<'_>,
443    ) -> FluentValue<'a> {
444        bool_fluent_value(self.value)
445    }
446}
447
448impl<'a, 'value> IntoFluentArgumentValue<'a> for FluentBorrowedArgumentValue<'value, bool> {
449    fn into_fluent_argument_value(
450        self,
451        _localize: &mut FluentMessageLookup<'_>,
452    ) -> FluentValue<'a> {
453        bool_fluent_value(*self.value)
454    }
455}
456
457impl<'a, 'value, 'inner> IntoFluentArgumentValue<'a>
458    for FluentBorrowedArgumentValue<'value, &'inner bool>
459{
460    fn into_fluent_argument_value(
461        self,
462        _localize: &mut FluentMessageLookup<'_>,
463    ) -> FluentValue<'a> {
464        bool_fluent_value(**self.value)
465    }
466}
467
468impl<'a> IntoFluentArgumentValue<'a> for FluentArgumentValue<StaticFluentVariantKey> {
469    fn into_fluent_argument_value(
470        self,
471        _localize: &mut FluentMessageLookup<'_>,
472    ) -> FluentValue<'a> {
473        self.value.as_str().into()
474    }
475}
476
477impl<'a> IntoFluentArgumentValue<'a> for FluentOptionalArgumentValue<StaticFluentVariantKey> {
478    fn into_fluent_argument_value(
479        self,
480        _localize: &mut FluentMessageLookup<'_>,
481    ) -> FluentValue<'a> {
482        match self.value {
483            Some(value) => value.as_str().into(),
484            None => FluentValue::None,
485        }
486    }
487}
488
489fn bool_fluent_value<'a>(value: bool) -> FluentValue<'a> {
490    if value { "true" } else { "false" }.into()
491}
492
493impl<'a> IntoFluentArgumentValue<'a> for FluentOptionalArgumentValue<&bool> {
494    fn into_fluent_argument_value(
495        self,
496        _localize: &mut FluentMessageLookup<'_>,
497    ) -> FluentValue<'a> {
498        match self.value {
499            Some(value) => bool_fluent_value(*value),
500            None => FluentValue::None,
501        }
502    }
503}
504
505impl<'a> IntoFluentArgumentValue<'a> for FluentOptionalArgumentValue<&&bool> {
506    fn into_fluent_argument_value(
507        self,
508        _localize: &mut FluentMessageLookup<'_>,
509    ) -> FluentValue<'a> {
510        match self.value {
511            Some(value) => bool_fluent_value(**value),
512            None => FluentValue::None,
513        }
514    }
515}
516
517impl<'a, T> IntoFluentArgumentValue<'a> for FluentOptionalArgumentValue<T>
518where
519    T: FluentMessage,
520{
521    fn into_fluent_argument_value(self, localize: &mut FluentMessageLookup<'_>) -> FluentValue<'a> {
522        match self.value {
523            Some(value) => value.to_fluent_string_with(localize).into(),
524            None => FluentValue::None,
525        }
526    }
527}
528
529impl<'a, T> IntoFluentArgumentValue<'a> for &FluentOptionalArgumentValue<&T>
530where
531    T: Clone + IntoFluentValue<'a>,
532{
533    fn into_fluent_argument_value(
534        self,
535        _localize: &mut FluentMessageLookup<'_>,
536    ) -> FluentValue<'a> {
537        match self.value {
538            Some(value) => (*value).clone().into_fluent_value(),
539            None => FluentValue::None,
540        }
541    }
542}
543
544impl<'a, T> IntoFluentArgumentValue<'a> for FluentArgumentValue<Option<T>>
545where
546    T: FluentMessage,
547{
548    fn into_fluent_argument_value(self, localize: &mut FluentMessageLookup<'_>) -> FluentValue<'a> {
549        match self.value {
550            Some(value) => value.to_fluent_string_with(localize).into(),
551            None => FluentValue::None,
552        }
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use std::sync::{Mutex, RwLock, mpsc};
560    use std::time::Duration;
561
562    fn static_domain(value: &'static str) -> StaticFluentDomain {
563        StaticFluentDomain::try_new(value).expect("valid test domain")
564    }
565
566    fn static_entry(value: &'static str) -> StaticFluentEntryId {
567        StaticFluentEntryId::try_new(value).expect("valid test message id")
568    }
569
570    fn panic_lookup<'a>(
571        _domain: StaticFluentDomain,
572        _id: StaticFluentEntryId,
573        _args: Option<&FluentArgs<'a>>,
574    ) -> String {
575        panic!("ordinary arguments should not invoke nested localization")
576    }
577
578    fn assert_string(value: FluentValue<'_>, expected: &str) {
579        match value {
580            FluentValue::String(value) => assert_eq!(value.as_ref(), expected),
581            other => panic!("expected string FluentValue, got {other:?}"),
582        }
583    }
584
585    fn assert_number(value: FluentValue<'_>, expected: f64) {
586        match value {
587            FluentValue::Number(value) => assert_eq!(value.value, expected),
588            other => panic!("expected number FluentValue, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn argument_conversion_handles_primitive_values() {
594        let mut localize = panic_lookup;
595
596        let string_value =
597            FluentArgumentValue::new("borrowed").into_fluent_argument_value(&mut localize);
598        assert_string(string_value, "borrowed");
599
600        let number_value =
601            FluentArgumentValue::new(42i32).into_fluent_argument_value(&mut localize);
602        assert_number(number_value, 42.0);
603
604        let bool_value = FluentArgumentValue::new(true).into_fluent_argument_value(&mut localize);
605        assert_string(bool_value, "true");
606
607        let false_value = FluentArgumentValue::new(false).into_fluent_argument_value(&mut localize);
608        assert_string(false_value, "false");
609
610        let choice_value = FluentArgumentValue::new(
611            StaticFluentVariantKey::try_new("selected").expect("valid choice"),
612        )
613        .into_fluent_argument_value(&mut localize);
614        assert_string(choice_value, "selected");
615
616        let borrowed_bool = true;
617        let borrowed_bool_value = FluentBorrowedArgumentValue::new(&borrowed_bool)
618            .into_fluent_argument_value(&mut localize);
619        assert_string(borrowed_bool_value, "true");
620    }
621
622    #[test]
623    #[should_panic(expected = "ordinary arguments should not invoke nested localization")]
624    fn panic_lookup_reports_unexpected_nested_localization() {
625        let _ = panic_lookup(static_domain("domain"), static_entry("id"), None);
626    }
627
628    #[test]
629    fn argument_conversion_handles_optional_and_missing_values() {
630        let mut localize = panic_lookup;
631        let optional = Some("optional");
632        let missing: Option<String> = None;
633        let optional_number = Some(7i32);
634        let optional_bool = Some(false);
635        let missing_bool: Option<bool> = None;
636
637        let optional_value = FluentOptionalArgumentValue::new(optional.as_ref())
638            .into_fluent_argument_value(&mut localize);
639        assert_string(optional_value, "optional");
640
641        let missing_value = FluentOptionalArgumentValue::new(missing.as_ref())
642            .into_fluent_argument_value(&mut localize);
643        assert!(matches!(missing_value, FluentValue::None));
644
645        let optional_number = FluentOptionalArgumentValue::new(optional_number.as_ref())
646            .into_fluent_argument_value(&mut localize);
647        assert_number(optional_number, 7.0);
648
649        let optional_bool = FluentOptionalArgumentValue::new(optional_bool.as_ref())
650            .into_fluent_argument_value(&mut localize);
651        assert_string(optional_bool, "false");
652
653        let missing_bool = FluentOptionalArgumentValue::new(missing_bool.as_ref())
654            .into_fluent_argument_value(&mut localize);
655        assert!(matches!(missing_bool, FluentValue::None));
656    }
657
658    #[test]
659    fn argument_conversion_handles_borrowed_and_owned_values() {
660        let mut localize = panic_lookup;
661        let borrowed = String::from("borrowed string");
662
663        let borrowed_value =
664            FluentArgumentValue::new(&borrowed).into_fluent_argument_value(&mut localize);
665        assert_string(borrowed_value, "borrowed string");
666
667        let owned_value = FluentArgumentValue::new(String::from("owned string"))
668            .into_fluent_argument_value(&mut localize);
669        assert_string(owned_value, "owned string");
670    }
671
672    struct NestedMessage;
673
674    impl FluentMessage for NestedMessage {
675        fn to_fluent_string_with(&self, localize: &mut FluentMessageLookup<'_>) -> String {
676            localize(
677                static_domain("nested-domain"),
678                static_entry("nested-id"),
679                None,
680            )
681        }
682    }
683
684    #[test]
685    fn argument_conversion_localizes_nested_messages_with_current_callback() {
686        let mut localize =
687            |domain: StaticFluentDomain, id: StaticFluentEntryId, args: Option<&FluentArgs<'_>>| {
688                assert_eq!(domain.as_str(), "nested-domain");
689                assert_eq!(id.as_str(), "nested-id");
690                assert!(args.is_none());
691                "nested value".to_string()
692            };
693
694        let value =
695            FluentArgumentValue::new(NestedMessage).into_fluent_argument_value(&mut localize);
696        assert_string(value, "nested value");
697    }
698
699    #[test]
700    fn argument_conversion_localizes_optional_nested_messages_with_current_callback() {
701        let mut localize =
702            |domain: StaticFluentDomain, id: StaticFluentEntryId, args: Option<&FluentArgs<'_>>| {
703                assert_eq!(domain.as_str(), "nested-domain");
704                assert_eq!(id.as_str(), "nested-id");
705                assert!(args.is_none());
706                "optional nested value".to_string()
707            };
708
709        let value =
710            FluentArgumentValue::new(Some(NestedMessage)).into_fluent_argument_value(&mut localize);
711        assert_string(value, "optional nested value");
712
713        let missing = FluentArgumentValue::new(Option::<NestedMessage>::None)
714            .into_fluent_argument_value(&mut localize);
715        assert!(matches!(missing, FluentValue::None));
716    }
717
718    struct StaticLocalizer {
719        value: &'static str,
720    }
721
722    impl FluentLocalizer for StaticLocalizer {
723        fn localize<'a>(
724            &self,
725            id: StaticFluentEntryId,
726            _args: Option<&FluentArgs<'a>>,
727        ) -> Option<String> {
728            if id == "nested-id" {
729                Some(self.value.to_string())
730            } else {
731                None
732            }
733        }
734
735        fn localize_in_domain<'a>(
736            &self,
737            domain: StaticFluentDomain,
738            id: StaticFluentEntryId,
739            args: Option<&FluentArgs<'a>>,
740        ) -> Option<String> {
741            if domain == "nested-domain" {
742                self.localize(id, args)
743            } else {
744                None
745            }
746        }
747    }
748
749    #[test]
750    fn localize_message_uses_the_explicit_localizer() {
751        let en = StaticLocalizer { value: "Hello" };
752        let fr = StaticLocalizer { value: "Bonjour" };
753
754        assert_eq!(en.localize_message(&NestedMessage), "Hello");
755        assert_eq!(fr.localize_message(&NestedMessage), "Bonjour");
756        assert_eq!(en.localize_message(&NestedMessage), "Hello");
757    }
758
759    struct MissingMessage;
760
761    impl FluentMessage for MissingMessage {
762        fn to_fluent_string_with(&self, localize: &mut FluentMessageLookup<'_>) -> String {
763            localize(
764                static_domain("missing-domain"),
765                static_entry("missing-id"),
766                None,
767            )
768        }
769    }
770
771    struct CallbackOnlyMessage;
772
773    impl FluentMessage for CallbackOnlyMessage {
774        fn to_fluent_string_with(&self, localize: &mut FluentMessageLookup<'_>) -> String {
775            localize(
776                static_domain("callback-domain"),
777                static_entry("callback-id"),
778                None,
779            )
780        }
781    }
782
783    #[test]
784    fn fluent_message_reference_impl_delegates_to_inner_message() {
785        let message = NestedMessage;
786        let message_ref = &message;
787        let mut localize = |domain: StaticFluentDomain,
788                            id: StaticFluentEntryId,
789                            _args: Option<&FluentArgs<'_>>| {
790            format!("{}:{}", domain.as_str(), id.as_str())
791        };
792
793        assert_eq!(
794            FluentMessage::to_fluent_string_with(&message_ref, &mut localize),
795            "nested-domain:nested-id"
796        );
797    }
798
799    #[test]
800    fn manual_fluent_message_uses_supplied_callback_for_lookup() {
801        let mut called = false;
802        let mut localize =
803            |domain: StaticFluentDomain, id: StaticFluentEntryId, args: Option<&FluentArgs<'_>>| {
804                called = true;
805                assert_eq!(domain.as_str(), "callback-domain");
806                assert_eq!(id.as_str(), "callback-id");
807                assert!(args.is_none());
808                "callback result".to_string()
809            };
810
811        assert_eq!(
812            CallbackOnlyMessage.to_fluent_string_with(&mut localize),
813            "callback result"
814        );
815        assert!(called);
816    }
817
818    #[test]
819    fn fluent_localizer_reference_and_arc_impls_delegate_to_inner_localizer() {
820        let localizer = StaticLocalizer { value: "Hello" };
821        let localizer_ref = &localizer;
822        let localizer_arc = Arc::new(StaticLocalizer { value: "Bonjour" });
823
824        assert_eq!(localizer_ref.localize_message(&NestedMessage), "Hello");
825        assert_eq!(localizer_arc.localize_message(&NestedMessage), "Bonjour");
826        assert_eq!(
827            FluentLocalizer::localize(&localizer_ref, static_entry("nested-id"), None),
828            Some("Hello".to_string())
829        );
830        assert_eq!(
831            FluentLocalizer::localize_in_domain(
832                &localizer_ref,
833                static_domain("nested-domain"),
834                static_entry("nested-id"),
835                None,
836            ),
837            Some("Hello".to_string())
838        );
839        assert_eq!(
840            FluentLocalizer::localize_in_domain(
841                &localizer_arc,
842                static_domain("nested-domain"),
843                static_entry("nested-id"),
844                None,
845            ),
846            Some("Bonjour".to_string())
847        );
848    }
849
850    #[test]
851    fn localizer_extension_localizes_typed_messages_with_id_fallback() {
852        let localizer = StaticLocalizer { value: "Hello" };
853
854        assert_eq!(
855            FluentLocalizer::localize(&localizer, static_entry("nested-id"), None),
856            Some("Hello".to_string())
857        );
858        assert_eq!(
859            FluentLocalizer::localize_in_domain(
860                &localizer,
861                static_domain("nested-domain"),
862                static_entry("nested-id"),
863                None,
864            ),
865            Some("Hello".to_string())
866        );
867        assert_eq!(localizer.localize_message(&MissingMessage), "missing-id");
868    }
869
870    #[test]
871    fn localizer_extension_can_return_missing_typed_messages_without_id_fallback() {
872        let localizer = StaticLocalizer { value: "Hello" };
873
874        assert_eq!(
875            localizer.try_localize_message(&NestedMessage),
876            Some("Hello".to_string())
877        );
878        assert_eq!(localizer.try_localize_message(&MissingMessage), None);
879    }
880
881    struct MinimalScopedLocalizer;
882
883    impl MinimalScopedLocalizer {
884        fn lookup(
885            &self,
886            domain: StaticFluentDomain,
887            id: StaticFluentEntryId,
888            _args: Option<&FluentArgs<'_>>,
889        ) -> Option<String> {
890            Some(format!("{domain}:{id}"))
891        }
892    }
893
894    impl FluentLocalizer for MinimalScopedLocalizer {
895        fn localize<'a>(
896            &self,
897            id: StaticFluentEntryId,
898            args: Option<&FluentArgs<'a>>,
899        ) -> Option<String> {
900            self.localize_in_domain(
901                StaticFluentDomain::from_package_name(env!("CARGO_PKG_NAME")),
902                id,
903                args,
904            )
905        }
906
907        fn localize_in_domain<'a>(
908            &self,
909            domain: StaticFluentDomain,
910            id: StaticFluentEntryId,
911            args: Option<&FluentArgs<'a>>,
912        ) -> Option<String> {
913            self.lookup(domain, id, args)
914        }
915
916        fn with_lookup(&self, f: &mut dyn FnMut(&mut FluentLocalizerLookup<'_>)) {
917            let mut lookup = |domain: StaticFluentDomain,
918                              id: StaticFluentEntryId,
919                              args: Option<&FluentArgs<'_>>| {
920                self.localize_in_domain(domain, id, args)
921            };
922            f(&mut lookup);
923        }
924    }
925
926    struct ScopedMessage;
927
928    impl FluentMessage for ScopedMessage {
929        fn to_fluent_string_with(&self, localize: &mut FluentMessageLookup<'_>) -> String {
930            localize(
931                static_domain("custom-domain"),
932                static_entry("scoped-message"),
933                None,
934            )
935        }
936    }
937
938    #[test]
939    fn custom_localizer_with_lookup_invokes_callback_and_renders_typed_message() {
940        assert_eq!(
941            MinimalScopedLocalizer.localize_message(&ScopedMessage),
942            "custom-domain:scoped-message"
943        );
944    }
945
946    struct SkippingCallbackLocalizer;
947
948    impl FluentLocalizer for SkippingCallbackLocalizer {
949        fn localize<'a>(
950            &self,
951            _id: StaticFluentEntryId,
952            _args: Option<&FluentArgs<'a>>,
953        ) -> Option<String> {
954            None
955        }
956
957        fn localize_in_domain<'a>(
958            &self,
959            _domain: StaticFluentDomain,
960            _id: StaticFluentEntryId,
961            _args: Option<&FluentArgs<'a>>,
962        ) -> Option<String> {
963            None
964        }
965
966        fn with_lookup(&self, _f: &mut dyn FnMut(&mut FluentLocalizerLookup<'_>)) {}
967    }
968
969    struct DoubleCallbackLocalizer;
970
971    impl FluentLocalizer for DoubleCallbackLocalizer {
972        fn localize<'a>(
973            &self,
974            id: StaticFluentEntryId,
975            _args: Option<&FluentArgs<'a>>,
976        ) -> Option<String> {
977            Some(id.as_str().to_string())
978        }
979
980        fn localize_in_domain<'a>(
981            &self,
982            _domain: StaticFluentDomain,
983            id: StaticFluentEntryId,
984            args: Option<&FluentArgs<'a>>,
985        ) -> Option<String> {
986            self.localize(id, args)
987        }
988
989        fn with_lookup(&self, f: &mut dyn FnMut(&mut FluentLocalizerLookup<'_>)) {
990            let mut lookup =
991                |_domain: StaticFluentDomain,
992                 id: StaticFluentEntryId,
993                 _args: Option<&FluentArgs<'_>>| { Some(id.as_str().to_string()) };
994            f(&mut lookup);
995            f(&mut lookup);
996        }
997    }
998
999    #[test]
1000    #[should_panic(expected = "FluentLocalizer::with_lookup must invoke its callback exactly once")]
1001    fn localize_message_panics_when_with_lookup_skips_callback() {
1002        SkippingCallbackLocalizer.localize_message(&NestedMessage);
1003    }
1004
1005    #[test]
1006    #[should_panic(expected = "FluentLocalizer::with_lookup must invoke its callback exactly once")]
1007    fn try_localize_message_panics_when_with_lookup_invokes_callback_twice() {
1008        let _ = DoubleCallbackLocalizer.try_localize_message(&NestedMessage);
1009    }
1010
1011    struct BlockingSwitchLocalizer {
1012        selected: RwLock<&'static str>,
1013        child_seen: Mutex<mpsc::Sender<()>>,
1014        continue_child: Mutex<mpsc::Receiver<()>>,
1015    }
1016
1017    impl BlockingSwitchLocalizer {
1018        fn new(child_seen: mpsc::Sender<()>, continue_child: mpsc::Receiver<()>) -> Self {
1019            Self {
1020                selected: RwLock::new("en"),
1021                child_seen: Mutex::new(child_seen),
1022                continue_child: Mutex::new(continue_child),
1023            }
1024        }
1025
1026        fn select(&self, language: &'static str) {
1027            *self
1028                .selected
1029                .write()
1030                .expect("test language lock should not be poisoned") = language;
1031        }
1032
1033        fn selected(&self) -> &'static str {
1034            *self
1035                .selected
1036                .read()
1037                .expect("test language lock should not be poisoned")
1038        }
1039
1040        fn render_lookup(&self, language: &'static str, domain: &str, id: &str) -> Option<String> {
1041            if domain != "switch-domain" {
1042                return None;
1043            }
1044
1045            if id == "child" {
1046                self.child_seen
1047                    .lock()
1048                    .expect("test child sender lock should not be poisoned")
1049                    .send(())
1050                    .expect("test should receive child lookup notification");
1051                self.continue_child
1052                    .lock()
1053                    .expect("test child receiver lock should not be poisoned")
1054                    .recv()
1055                    .expect("test should release child lookup");
1056            }
1057
1058            matches!(id, "child" | "parent").then(|| format!("{language}-{id}"))
1059        }
1060    }
1061
1062    impl FluentLocalizer for BlockingSwitchLocalizer {
1063        fn localize<'a>(
1064            &self,
1065            id: StaticFluentEntryId,
1066            _args: Option<&FluentArgs<'a>>,
1067        ) -> Option<String> {
1068            let language = self.selected();
1069            self.render_lookup(language, "switch-domain", id.as_str())
1070        }
1071
1072        fn localize_in_domain<'a>(
1073            &self,
1074            domain: StaticFluentDomain,
1075            id: StaticFluentEntryId,
1076            _args: Option<&FluentArgs<'a>>,
1077        ) -> Option<String> {
1078            let language = self.selected();
1079            self.render_lookup(language, domain.as_str(), id.as_str())
1080        }
1081
1082        fn with_lookup(&self, f: &mut dyn FnMut(&mut FluentLocalizerLookup<'_>)) {
1083            let selected = self
1084                .selected
1085                .read()
1086                .expect("test language lock should not be poisoned");
1087            let language = *selected;
1088            let mut lookup = |domain: StaticFluentDomain,
1089                              id: StaticFluentEntryId,
1090                              _args: Option<&FluentArgs<'_>>| {
1091                self.render_lookup(language, domain.as_str(), id.as_str())
1092            };
1093
1094            f(&mut lookup);
1095        }
1096    }
1097
1098    struct BlockingParent;
1099
1100    impl FluentMessage for BlockingParent {
1101        fn to_fluent_string_with(&self, localize: &mut FluentMessageLookup<'_>) -> String {
1102            let child = localize(static_domain("switch-domain"), static_entry("child"), None);
1103            let parent = localize(static_domain("switch-domain"), static_entry("parent"), None);
1104            format!("{parent}:{child}")
1105        }
1106    }
1107
1108    #[test]
1109    fn localize_message_keeps_one_lookup_scope_during_concurrent_language_switch() {
1110        let (child_seen_tx, child_seen_rx) = mpsc::channel();
1111        let (continue_child_tx, continue_child_rx) = mpsc::channel();
1112        let localizer = Arc::new(BlockingSwitchLocalizer::new(
1113            child_seen_tx,
1114            continue_child_rx,
1115        ));
1116
1117        let render_localizer = Arc::clone(&localizer);
1118        let render = std::thread::spawn(move || render_localizer.localize_message(&BlockingParent));
1119
1120        child_seen_rx
1121            .recv_timeout(Duration::from_secs(1))
1122            .expect("render should reach the child lookup");
1123
1124        let (switch_started_tx, switch_started_rx) = mpsc::channel();
1125        let (switch_done_tx, switch_done_rx) = mpsc::channel();
1126        let switch_localizer = Arc::clone(&localizer);
1127        let switch = std::thread::spawn(move || {
1128            switch_started_tx
1129                .send(())
1130                .expect("test should observe language switch start");
1131            switch_localizer.select("fr");
1132            switch_done_tx
1133                .send(())
1134                .expect("test should observe language switch completion");
1135        });
1136
1137        switch_started_rx
1138            .recv_timeout(Duration::from_secs(1))
1139            .expect("language switch thread should start");
1140        assert!(
1141            switch_done_rx
1142                .recv_timeout(Duration::from_millis(50))
1143                .is_err(),
1144            "language switch completed while typed message render was still in progress"
1145        );
1146
1147        continue_child_tx
1148            .send(())
1149            .expect("test should release the child lookup");
1150
1151        let rendered = render
1152            .join()
1153            .expect("render thread should complete without panicking");
1154        switch_done_rx
1155            .recv_timeout(Duration::from_secs(1))
1156            .expect("language switch should complete after render");
1157        switch
1158            .join()
1159            .expect("language switch thread should complete without panicking");
1160
1161        assert_eq!(rendered, "en-parent:en-child");
1162        assert_eq!(localizer.selected(), "fr");
1163    }
1164}