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#[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
37pub type FluentMessageLookup<'lookup> = dyn for<'a> FnMut(StaticFluentDomain, StaticFluentEntryId, Option<&'a FluentArgs<'a>>) -> String
39 + 'lookup;
40
41pub type FluentLocalizerLookup<'lookup> = dyn for<'a> FnMut(
43 StaticFluentDomain,
44 StaticFluentEntryId,
45 Option<&'a FluentArgs<'a>>,
46 ) -> Option<String>
47 + 'lookup;
48
49pub trait FluentMessage {
56 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
72pub trait FluentLocalizer {
86 fn localize<'a>(
89 &self,
90 id: StaticFluentEntryId,
91 args: Option<&'a FluentArgs<'a>>,
92 ) -> Option<String>;
93
94 fn localize_in_domain<'a>(
97 &self,
98 domain: StaticFluentDomain,
99 id: StaticFluentEntryId,
100 args: Option<&'a FluentArgs<'a>>,
101 ) -> Option<String>;
102
103 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
247pub trait FluentLocalizerExt: FluentLocalizer {
253 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 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#[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#[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#[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#[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}