1use fluent::concurrent::FluentBundle;
2use fluent::{FluentAttribute, FluentMessage, FluentResource};
3use fluent_syntax::ast::{CallArguments, Expression, InlineExpression, Pattern, PatternElement};
4use i18n_embed::{fluent::FluentLanguageLoader, FileSystemAssets, LanguageLoader};
5use proc_macro::TokenStream;
6use proc_macro_error2::{abort, emit_error, proc_macro_error};
7use quote::quote;
8use std::{
9 collections::{HashMap, HashSet},
10 path::Path,
11 sync::OnceLock,
12};
13
14#[cfg(feature = "dashmap")]
15use dashmap::mapref::one::Ref;
16#[cfg(not(feature = "dashmap"))]
17use std::sync::{Arc, RwLock};
18
19use syn::{parse::Parse, parse_macro_input, spanned::Spanned};
20use unic_langid::LanguageIdentifier;
21
22#[cfg(doctest)]
23#[macro_use]
24extern crate doc_comment;
25
26#[cfg(doctest)]
27doctest!("../README.md");
28
29#[derive(Debug)]
30enum FlAttr {
31 Attr(syn::Lit),
33 None,
35}
36
37impl Parse for FlAttr {
38 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
39 if !input.is_empty() {
40 let fork = input.fork();
41 fork.parse::<syn::Token![,]>()?;
42 if fork.parse::<syn::Lit>().is_ok()
43 && (fork.parse::<syn::Token![,]>().is_ok() || fork.is_empty())
44 {
45 input.parse::<syn::Token![,]>()?;
46 let literal = input.parse::<syn::Lit>()?;
47 Ok(Self::Attr(literal))
48 } else {
49 Ok(Self::None)
50 }
51 } else {
52 Ok(Self::None)
53 }
54 }
55}
56
57#[derive(Debug)]
58enum FlArgs {
59 HashMap(syn::Expr),
62 KeyValuePairs {
69 specified_args: HashMap<syn::LitStr, Box<syn::Expr>>,
70 },
71 None,
73}
74
75impl Parse for FlArgs {
76 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
77 if !input.is_empty() {
78 input.parse::<syn::Token![,]>()?;
79
80 let lookahead = input.fork();
81 if lookahead.parse::<syn::ExprAssign>().is_err() {
82 let hash_map = input.parse()?;
83 return Ok(FlArgs::HashMap(hash_map));
84 }
85
86 let mut args_map: HashMap<syn::LitStr, Box<syn::Expr>> = HashMap::new();
87
88 while let Ok(expr) = input.parse::<syn::ExprAssign>() {
89 let argument_name_ident_opt = match &*expr.left {
90 syn::Expr::Path(path) => path.path.get_ident(),
91 _ => None,
92 };
93
94 let argument_name_ident = match argument_name_ident_opt {
95 Some(ident) => ident,
96 None => {
97 return Err(syn::Error::new(
98 expr.left.span(),
99 "fl!() unable to parse argument identifier",
100 ))
101 }
102 }
103 .clone();
104
105 let argument_name_string = argument_name_ident.to_string();
106 let argument_name_lit_str =
107 syn::LitStr::new(&argument_name_string, argument_name_ident.span());
108
109 let argument_value = expr.right;
110
111 if let Some(_duplicate) = args_map.insert(argument_name_lit_str, argument_value) {
112 let argument_name_lit_str =
114 syn::LitStr::new(&argument_name_string, argument_name_ident.span());
115 return Err(syn::Error::new(
116 argument_name_lit_str.span(),
117 format!(
118 "fl!() macro contains a duplicate argument `{}`",
119 argument_name_lit_str.value()
120 ),
121 ));
122 }
123
124 let _result = input.parse::<syn::Token![,]>();
126 }
127
128 if args_map.is_empty() {
129 let span = match input.fork().parse::<syn::Expr>() {
130 Ok(expr) => expr.span(),
131 Err(_) => input.span(),
132 };
133 Err(syn::Error::new(span, "fl!() unable to parse args input"))
134 } else {
135 Ok(FlArgs::KeyValuePairs {
136 specified_args: args_map,
137 })
138 }
139 } else {
140 Ok(FlArgs::None)
141 }
142 }
143}
144
145struct FlMacroInput {
147 fluent_loader: syn::Expr,
148 message_id: syn::Lit,
149 attr: FlAttr,
150 args: FlArgs,
151}
152
153impl Parse for FlMacroInput {
154 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
155 let fluent_loader = input.parse()?;
156 input.parse::<syn::Token![,]>()?;
157 let message_id = input.parse()?;
158 let attr = input.parse()?;
159 let args = input.parse()?;
160
161 Ok(Self {
162 fluent_loader,
163 message_id,
164 attr,
165 args,
166 })
167 }
168}
169
170struct DomainSpecificData {
171 loader: FluentLanguageLoader,
172 _assets: FileSystemAssets,
173}
174
175#[derive(Default)]
176struct DomainsMap {
177 #[cfg(not(feature = "dashmap"))]
178 map: RwLock<HashMap<String, Arc<DomainSpecificData>>>,
179
180 #[cfg(feature = "dashmap")]
181 map: dashmap::DashMap<String, DomainSpecificData>,
182}
183
184#[cfg(feature = "dashmap")]
185impl DomainsMap {
186 fn get(&self, domain: &String) -> Option<Ref<String, DomainSpecificData>> {
187 self.map.get(domain)
188 }
189
190 fn entry_or_insert(
191 &self,
192 domain: &String,
193 data: DomainSpecificData,
194 ) -> Ref<String, DomainSpecificData> {
195 self.map.entry(domain.clone()).or_insert(data).downgrade()
196 }
197}
198
199#[cfg(not(feature = "dashmap"))]
200impl DomainsMap {
201 fn get(&self, domain: &String) -> Option<Arc<DomainSpecificData>> {
202 match self.map.read().unwrap().get(domain) {
203 None => None,
204 Some(data) => Some(data.clone()),
205 }
206 }
207
208 fn entry_or_insert(
209 &self,
210 domain: &String,
211 data: DomainSpecificData,
212 ) -> Arc<DomainSpecificData> {
213 self.map
214 .write()
215 .unwrap()
216 .entry(domain.clone())
217 .or_insert(Arc::new(data))
218 .clone()
219 }
220}
221
222fn domains() -> &'static DomainsMap {
223 static DOMAINS: OnceLock<DomainsMap> = OnceLock::new();
224
225 DOMAINS.get_or_init(|| DomainsMap::default())
226}
227
228#[proc_macro]
390#[proc_macro_error]
391pub fn fl(input: TokenStream) -> TokenStream {
392 let input: FlMacroInput = parse_macro_input!(input as FlMacroInput);
393
394 let fluent_loader = input.fluent_loader;
395 let message_id = input.message_id;
396
397 let domain = {
398 let manifest = find_crate::Manifest::new().expect("Error reading Cargo.toml");
399 manifest.crate_package().map(|pkg| pkg.name).unwrap_or(
400 std::env::var("CARGO_PKG_NAME").expect("Error fetching `CARGO_PKG_NAME` env"),
401 )
402 };
403
404 let domain_data = if let Some(domain_data) = domains().get(&domain) {
405 domain_data
406 } else {
407 let crate_paths = i18n_config::locate_crate_paths()
408 .unwrap_or_else(|error| panic!("fl!() is unable to locate crate paths: {}", error));
409
410 let config_file_path = &crate_paths.i18n_config_file;
411
412 let config = i18n_config::I18nConfig::from_file(config_file_path).unwrap_or_else(|err| {
413 abort! {
414 proc_macro2::Span::call_site(),
415 format!(
416 "fl!() had a problem reading i18n config file {config_file_path:?}: {err}"
417 );
418 help = "Try creating the `i18n.toml` configuration file.";
419 }
420 });
421
422 let fluent_config = config.fluent.unwrap_or_else(|| {
423 abort! {
424 proc_macro2::Span::call_site(),
425 format!(
426 "fl!() had a problem parsing i18n config file {config_file_path:?}: \
427 there is no `[fluent]` subsection."
428 );
429 help = "Add the `[fluent]` subsection to `i18n.toml`, \
430 along with its required `assets_dir`.";
431 }
432 });
433
434 let domain = fluent_config.domain.unwrap_or(domain);
436
437 let assets_dir = Path::new(&crate_paths.crate_dir).join(fluent_config.assets_dir);
438 let assets = FileSystemAssets::try_new(assets_dir).unwrap();
439
440 let fallback_language: LanguageIdentifier = config.fallback_language;
441
442 let loader = FluentLanguageLoader::new(&domain, fallback_language.clone());
443
444 loader
445 .load_languages(&assets, &[fallback_language.clone()])
446 .unwrap_or_else(|err| match err {
447 i18n_embed::I18nEmbedError::LanguageNotAvailable(file, language_id) => {
448 if fallback_language != language_id {
449 panic!(
450 "fl!() encountered an unexpected problem, \
451 the language being loaded (\"{0}\") is not the \
452 `fallback_language` (\"{1}\")",
453 language_id, fallback_language
454 )
455 }
456 abort! {
457 proc_macro2::Span::call_site(),
458 format!(
459 "fl!() was unable to load the localization \
460 file for the `fallback_language` \
461 (\"{fallback_language}\"): {file}"
462 );
463 help = "Try creating the required fluent localization file.";
464 }
465 }
466 _ => panic!(
467 "fl!() had an unexpected problem while \
468 loading language \"{0}\": {1}",
469 fallback_language, err
470 ),
471 });
472
473 let data = DomainSpecificData {
474 loader,
475 _assets: assets,
476 };
477
478 domains().entry_or_insert(&domain, data)
479 };
480
481 let message_id_string = match &message_id {
482 syn::Lit::Str(message_id_str) => {
483 let message_id_str = message_id_str.value();
484 Some(message_id_str)
485 }
486 unexpected_lit => {
487 emit_error! {
488 unexpected_lit,
489 "fl!() `message_id` should be a literal rust string"
490 };
491 None
492 }
493 };
494
495 let attr = input.attr;
496 let attr_str;
497 let attr_lit = match &attr {
498 FlAttr::Attr(literal) => match literal {
499 syn::Lit::Str(string_lit) => {
500 attr_str = Some(string_lit.value());
501 Some(literal)
502 }
503 unexpected_lit => {
504 attr_str = None;
505 emit_error! {
506 unexpected_lit,
507 "fl!() `message_id` should be a literal rust string"
508 };
509 None
510 }
511 },
512 FlAttr::None => {
513 attr_str = None;
514 None
515 }
516 };
517
518 let mut checked_loader_has_message = false;
522 let mut checked_message_has_attribute = false;
524
525 let gen = match input.args {
526 FlArgs::HashMap(args_hash_map) => {
527 if attr_lit.is_none() {
528 quote! {
529 (#fluent_loader).get_args(#message_id, #args_hash_map)
530 }
531 } else {
532 quote! {
533 (#fluent_loader).get_attr_args(#message_id, #attr_lit, #args_hash_map)
534 }
535 }
536 }
537 FlArgs::None => {
538 if attr_lit.is_none() {
539 quote! {
540 (#fluent_loader).get(#message_id)
541 }
542 } else {
543 quote! {
544 (#fluent_loader).get_attr(#message_id, #attr_lit)
545 }
546 }
547 }
548 FlArgs::KeyValuePairs { specified_args } => {
549 let mut arg_assignments = proc_macro2::TokenStream::default();
550 for (key, value) in &specified_args {
551 arg_assignments = quote! {
552 #arg_assignments
553 args.insert(#key, #value.into());
554 }
555 }
556
557 if attr_lit.is_none() {
558 if let Some(message_id_str) = &message_id_string {
559 checked_loader_has_message = domain_data
560 .loader
561 .with_fluent_message_and_bundle(message_id_str, |message, bundle| {
562 check_message_args(message, bundle, &specified_args);
563 })
564 .is_some();
565 }
566
567 let gen = quote! {
568 (#fluent_loader).get_args_concrete(
569 #message_id,
570 {
571 let mut args = std::collections::HashMap::new();
572 #arg_assignments
573 args
574 })
575 };
576
577 gen
578 } else {
579 if let Some(message_id_str) = &message_id_string {
580 if let Some(attr_id_str) = &attr_str {
581 let attr_res = domain_data.loader.with_fluent_message_and_bundle(
582 message_id_str,
583 |message, bundle| match message.get_attribute(attr_id_str) {
584 Some(attr) => {
585 check_attribute_args(attr, bundle, &specified_args);
586 true
587 }
588 None => false,
589 },
590 );
591 checked_loader_has_message = attr_res.is_some();
592 checked_message_has_attribute = attr_res.unwrap_or(false);
593 }
594 }
595
596 let gen = quote! {
597 (#fluent_loader).get_attr_args_concrete(
598 #message_id,
599 #attr_lit,
600 {
601 let mut args = std::collections::HashMap::new();
602 #arg_assignments
603 args
604 })
605 };
606
607 gen
608 }
609 }
610 };
611
612 if let Some(message_id_str) = &message_id_string {
613 if !checked_loader_has_message && !domain_data.loader.has(message_id_str) {
614 let suggestions =
615 fuzzy_message_suggestions(&domain_data.loader, message_id_str, 5).join("\n");
616
617 let hint = format!(
618 "Perhaps you are looking for one of the following messages?\n\n\
619 {suggestions}"
620 );
621
622 emit_error! {
623 message_id,
624 format!(
625 "fl!() `message_id` validation failed. `message_id` \
626 of \"{0}\" does not exist in the `fallback_language` (\"{1}\")",
627 message_id_str,
628 domain_data.loader.current_language(),
629 );
630 help = "Enter the correct `message_id` or create \
631 the message in the localization file if the \
632 intended message does not yet exist.";
633
634 hint = hint;
635 };
636 } else if let Some(attr_id_str) = &attr_str {
637 if !checked_message_has_attribute
638 && !&domain_data.loader.has_attr(message_id_str, attr_id_str)
639 {
640 let suggestions = &domain_data
641 .loader
642 .with_fluent_message(message_id_str, |message| {
643 fuzzy_attribute_suggestions(&message, attr_id_str, 5).join("\n")
644 })
645 .unwrap();
646
647 let hint = format!(
648 "Perhaps you are looking for one of the following attributes?\n\n\
649 {suggestions}"
650 );
651
652 emit_error! {
653 attr_lit,
654 format!(
655 "fl!() `attribute_id` validation failed. `attribute_id` \
656 of \"{0}\" does not exist in the `fallback_language` (\"{1}\")",
657 attr_id_str,
658 domain_data.loader.current_language(),
659 );
660 help = "Enter the correct `attribute_id` or create \
661 the attribute associated with the message in the localization file if the \
662 intended attribute does not yet exist.";
663
664 hint = hint;
665 };
666 }
667 }
668 }
669
670 gen.into()
671}
672
673fn fuzzy_message_suggestions(
674 loader: &FluentLanguageLoader,
675 message_id_str: &str,
676 n_suggestions: usize,
677) -> Vec<String> {
678 let mut scored_messages: Vec<(String, usize)> =
679 loader.with_message_iter(loader.fallback_language(), |message_iter| {
680 message_iter
681 .map(|message| {
682 (
683 message.id.name.to_string(),
684 strsim::levenshtein(message_id_str, message.id.name),
685 )
686 })
687 .collect()
688 });
689
690 scored_messages.sort_by_key(|(_message, score)| *score);
691
692 scored_messages.truncate(n_suggestions);
693
694 scored_messages
695 .into_iter()
696 .map(|(message, _score)| message)
697 .collect()
698}
699
700fn fuzzy_attribute_suggestions(
701 message: &FluentMessage<'_>,
702 attribute_id_str: &str,
703 n_suggestions: usize,
704) -> Vec<String> {
705 let mut scored_attributes: Vec<(String, usize)> = message
706 .attributes()
707 .map(|attribute| {
708 (
709 attribute.id().to_string(),
710 strsim::levenshtein(attribute_id_str, attribute.id()),
711 )
712 })
713 .collect();
714
715 scored_attributes.sort_by_key(|(_attr, score)| *score);
716
717 scored_attributes.truncate(n_suggestions);
718
719 scored_attributes
720 .into_iter()
721 .map(|(attribute, _score)| attribute)
722 .collect()
723}
724
725fn check_message_args<R>(
726 message: FluentMessage<'_>,
727 bundle: &FluentBundle<R>,
728 specified_args: &HashMap<syn::LitStr, Box<syn::Expr>>,
729) where
730 R: std::borrow::Borrow<FluentResource>,
731{
732 if let Some(pattern) = message.value() {
733 let mut args = Vec::new();
734 args_from_pattern(pattern, bundle, &mut args);
735
736 let args_set: HashSet<&str> = args.into_iter().collect();
737
738 let key_args: Vec<String> = specified_args
739 .keys()
740 .map(|key| {
741 let arg = key.value();
742
743 if !args_set.contains(arg.as_str()) {
744 let available_args: String = args_set
745 .iter()
746 .map(|arg| format!("`{arg}`"))
747 .collect::<Vec<String>>()
748 .join(", ");
749
750 emit_error! {
751 key,
752 format!(
753 "fl!() argument `{0}` does not exist in the \
754 fluent message. Available arguments: {1}.",
755 &arg, available_args
756 );
757 help = "Enter the correct arguments, or fix the message \
758 in the fluent localization file so that the arguments \
759 match this macro invocation.";
760 };
761 }
762
763 arg
764 })
765 .collect();
766
767 let key_args_set: HashSet<&str> = key_args.iter().map(|v| v.as_str()).collect();
768
769 let unspecified_args: Vec<String> = args_set
770 .iter()
771 .filter_map(|arg| {
772 if !key_args_set.contains(arg) {
773 Some(format!("`{arg}`"))
774 } else {
775 None
776 }
777 })
778 .collect();
779
780 if !unspecified_args.is_empty() {
781 emit_error! {
782 proc_macro2::Span::call_site(),
783 format!(
784 "fl!() the following arguments have not been specified: {}",
785 unspecified_args.join(", ")
786 );
787 help = "Enter the correct arguments, or fix the message \
788 in the fluent localization file so that the arguments \
789 match this macro invocation.";
790 };
791 }
792 }
793}
794
795fn check_attribute_args<R>(
796 attr: FluentAttribute<'_>,
797 bundle: &FluentBundle<R>,
798 specified_args: &HashMap<syn::LitStr, Box<syn::Expr>>,
799) where
800 R: std::borrow::Borrow<FluentResource>,
801{
802 let pattern = attr.value();
803 let mut args = Vec::new();
804 args_from_pattern(pattern, bundle, &mut args);
805
806 let args_set: HashSet<&str> = args.into_iter().collect();
807
808 let key_args: Vec<String> = specified_args
809 .keys()
810 .map(|key| {
811 let arg = key.value();
812
813 if !args_set.contains(arg.as_str()) {
814 let available_args: String = args_set
815 .iter()
816 .map(|arg| format!("`{arg}`"))
817 .collect::<Vec<String>>()
818 .join(", ");
819
820 emit_error! {
821 key,
822 format!(
823 "fl!() argument `{0}` does not exist in the \
824 fluent attribute. Available arguments: {1}.",
825 &arg, available_args
826 );
827 help = "Enter the correct arguments, or fix the attribute \
828 in the fluent localization file so that the arguments \
829 match this macro invocation.";
830 };
831 }
832
833 arg
834 })
835 .collect();
836
837 let key_args_set: HashSet<&str> = key_args.iter().map(|v| v.as_str()).collect();
838
839 let unspecified_args: Vec<String> = args_set
840 .iter()
841 .filter_map(|arg| {
842 if !key_args_set.contains(arg) {
843 Some(format!("`{arg}`"))
844 } else {
845 None
846 }
847 })
848 .collect();
849
850 if !unspecified_args.is_empty() {
851 emit_error! {
852 proc_macro2::Span::call_site(),
853 format!(
854 "fl!() the following arguments have not been specified: {}",
855 unspecified_args.join(", ")
856 );
857 help = "Enter the correct arguments, or fix the attribute \
858 in the fluent localization file so that the arguments \
859 match this macro invocation.";
860 };
861 }
862}
863
864fn args_from_pattern<'m, R>(
865 pattern: &Pattern<&'m str>,
866 bundle: &'m FluentBundle<R>,
867 args: &mut Vec<&'m str>,
868) where
869 R: std::borrow::Borrow<FluentResource>,
870{
871 pattern.elements.iter().for_each(|element| {
872 if let PatternElement::Placeable { expression } = element {
873 args_from_expression(expression, bundle, args)
874 }
875 });
876}
877
878fn args_from_expression<'m, R>(
879 expr: &Expression<&'m str>,
880 bundle: &'m FluentBundle<R>,
881 args: &mut Vec<&'m str>,
882) where
883 R: std::borrow::Borrow<FluentResource>,
884{
885 match expr {
886 Expression::Inline(inline_expr) => {
887 args_from_inline_expression(inline_expr, bundle, args);
888 }
889 Expression::Select { selector, variants } => {
890 args_from_inline_expression(selector, bundle, args);
891
892 variants.iter().for_each(|variant| {
893 args_from_pattern(&variant.value, bundle, args);
894 })
895 }
896 }
897}
898
899fn args_from_inline_expression<'m, R>(
900 inline_expr: &InlineExpression<&'m str>,
901 bundle: &'m FluentBundle<R>,
902 args: &mut Vec<&'m str>,
903) where
904 R: std::borrow::Borrow<FluentResource>,
905{
906 match inline_expr {
907 InlineExpression::FunctionReference {
908 id: _,
909 arguments: call_args,
910 } => {
911 args_from_call_arguments(call_args, bundle, args);
912 }
913 InlineExpression::TermReference {
914 id: _,
915 attribute: _,
916 arguments: Some(call_args),
917 } => {
918 args_from_call_arguments(call_args, bundle, args);
919 }
920 InlineExpression::VariableReference { id } => args.push(id.name),
921 InlineExpression::Placeable { expression } => {
922 args_from_expression(expression, bundle, args)
923 }
924 InlineExpression::MessageReference {
925 id,
926 attribute: None,
927 } => {
928 bundle
929 .get_message(&id.name)
930 .and_then(|m| m.value())
931 .map(|p| args_from_pattern(p, bundle, args));
932 }
933 InlineExpression::MessageReference {
934 id,
935 attribute: Some(attribute),
936 } => {
937 bundle
938 .get_message(&id.name)
939 .and_then(|m| m.get_attribute(&attribute.name))
940 .map(|m| m.value())
941 .map(|p| args_from_pattern(p, bundle, args));
942 }
943 _ => {}
944 }
945}
946
947fn args_from_call_arguments<'m, R>(
948 call_args: &CallArguments<&'m str>,
949 bundle: &'m FluentBundle<R>,
950 args: &mut Vec<&'m str>,
951) where
952 R: std::borrow::Borrow<FluentResource>,
953{
954 call_args.positional.iter().for_each(|expr| {
955 args_from_inline_expression(expr, bundle, args);
956 });
957
958 call_args.named.iter().for_each(|named_arg| {
959 args_from_inline_expression(&named_arg.value, bundle, args);
960 })
961}