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
9pub trait FluentMessage {
16 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
46pub trait FluentLocalizer {
62 fn localize<'a>(
64 &self,
65 id: &str,
66 args: Option<&HashMap<&str, FluentValue<'a>>>,
67 ) -> Option<String>;
68
69 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 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
260pub trait FluentLocalizerExt: FluentLocalizer {
266 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 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#[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#[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#[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#[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}