Skip to main content

es_fluent/traits/
fluent_message.rs

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