r18_proc_macros/
lib.rs

1#![cfg_attr(feature = "nightly-features", feature(track_path))]
2use std::{
3    collections::{BTreeMap, HashMap},
4    fs::File,
5    io::BufReader,
6    path::{Path, PathBuf},
7};
8
9use oxilangtag::LanguageTag;
10use proc_macro2::Ident;
11use quote::{format_ident, quote};
12use serde::Deserialize;
13use walkdir::WalkDir;
14
15struct PathStr(String);
16
17impl syn::parse::Parse for PathStr {
18    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
19        input.parse::<syn::LitStr>().map(|v| Self(v.value()))
20    }
21}
22
23#[derive(Debug, Default, Deserialize)]
24struct Config {
25    fallback: HashMap<String, String>,
26}
27
28struct LocaleExtra {
29    name: String,
30    ident: Ident,
31    translations: HashMap<String, String>,
32}
33
34type LocaleModel = BTreeMap<
35    String, // region
36    LocaleExtra,
37>;
38type TranslationModel = HashMap<
39    String, // primary language
40    LocaleModel,
41>;
42
43/// Generate translation models and functions `set_locale` and `locale` to setup
44/// `r18` environment with given translation directory.
45///
46/// ## Example
47///
48/// ```ignore
49/// r18::init!("tr");
50/// ```
51#[proc_macro]
52pub fn init(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
53    let path = match syn::parse::<PathStr>(input) {
54        Ok(dir) => {
55            let p = std::env::var("CARGO_MANIFEST_DIR")
56                .map(|p| PathBuf::from(p).join(dir.0))
57                .expect("CARGO_MANIFEST_DIR doesn't exist");
58
59            match p.is_dir() {
60                true => p,
61                false => panic!("{} is not a directory.", p.display()),
62            }
63        }
64        Err(e) => return e.to_compile_error().into(),
65    };
66
67    let (config, model) = scan_locales(path);
68    let locales = generate_locales(&model);
69    let locale_helpers = generate_helpers(&config, &model);
70
71    quote! {
72        #[doc(hidden)]
73        pub(crate) mod __r18_gen {
74            #locales
75            #locale_helpers
76        }
77    }
78    .into()
79}
80
81fn scan_locales(path: impl AsRef<Path>) -> (Config, TranslationModel) {
82    let mut model = TranslationModel::new();
83    let mut config = Config::default();
84
85    WalkDir::new(path)
86        .into_iter()
87        .filter_map(|p| {
88            let path = p.ok()?;
89            let mut parts = path.path().file_name()?.to_str()?.split('.').rev();
90
91            let language = match (parts.next(), parts.next(), parts.next()) {
92                (Some("json"), Some("config"), None) => {
93                    config = load_config(path.path());
94                    None
95                }
96                (Some("json"), Some(tag), None) => LanguageTag::parse_and_normalize(tag).ok(),
97                _ => None,
98            }?;
99
100            Some((path, language))
101        })
102        .for_each(|(path, language)| {
103            #[cfg(feature = "nightly-features")]
104            proc_macro::tracked_path::path(path.path().to_str().unwrap_or_default());
105
106            let region = language.region().unwrap_or_default().to_string();
107            let name = region
108                .is_empty()
109                .then_some(language.primary_language().into())
110                .unwrap_or_else(|| format!("{}-{}", language.primary_language(), region));
111
112            let extra = LocaleExtra {
113                ident: format_ident!("{}", name.replace('-', "_").to_uppercase()),
114                name,
115                translations: r18_trans_support::translation::extract(path.path()).unwrap(),
116            };
117
118            model
119                .entry(language.primary_language().into())
120                .or_default()
121                .insert(region, extra);
122        });
123
124    (config, model)
125}
126
127fn load_config(path: impl AsRef<Path>) -> Config {
128    File::open(path)
129        .ok()
130        .and_then(|f| serde_json::from_reader(BufReader::new(f)).ok())
131        .unwrap_or_default()
132}
133
134fn generate_primary(locales: &LocaleModel) -> proc_macro2::TokenStream {
135    locales
136        .iter()
137        .map(|(_, extra)| {
138            let code = &extra.ident;
139            let name = &extra.name;
140            let translation = extra.translations.iter().map(|(k, v)| quote!( #k => #v ));
141
142            quote! {
143                #[doc(hidden)]
144                const #code: ::r18::Locale = ::r18::Locale {
145                    name: #name,
146                    translate: {
147                        use ::r18::phf;
148                        phf::phf_map! {
149                            #( #translation ),*
150                        }
151                    }
152                };
153            }
154        })
155        .collect()
156}
157
158fn generate_locales(model: &TranslationModel) -> proc_macro2::TokenStream {
159    model
160        .iter()
161        .map(|(_, locales)| generate_primary(locales))
162        .collect()
163}
164
165fn generate_lang_matches(
166    config: &Config,
167    primary: &String,
168    locales: &LocaleModel,
169) -> proc_macro2::TokenStream {
170    let exact_matches =
171        locales
172            .iter()
173            .filter(|(region, _)| !region.is_empty())
174            .map(|(region, extra)| {
175                let ident = &extra.ident;
176                quote! { (#primary, Some(#region)) => Some(&#ident) , }
177            });
178
179    if let Some((_, extra)) = locales.first_key_value() {
180        let fallback_region = config
181            .fallback
182            .get(primary)
183            .and_then(|fallback| {
184                LanguageTag::parse_and_normalize(fallback)
185                    .ok()?
186                    .region()
187                    .map(|r| r.to_string())
188            })
189            .unwrap_or_default();
190
191        let fallback_match = locales
192            .get(&fallback_region)
193            .map(|extra| {
194                let ident = &extra.ident;
195                quote! { (#primary, _) => Some(&#ident) , }
196            })
197            .unwrap_or_else(|| {
198                let ident = &extra.ident;
199                quote! { (#primary, _) => Some(&#ident) , }
200            });
201
202        exact_matches.chain([fallback_match]).collect()
203    } else {
204        quote!()
205    }
206}
207
208fn generate_helpers(config: &Config, model: &TranslationModel) -> proc_macro2::TokenStream {
209    let matches = model
210        .iter()
211        .map(|(primary, locales)| generate_lang_matches(config, primary, locales))
212        .collect::<proc_macro2::TokenStream>();
213
214    quote! {
215        #[doc(hidden)]
216        pub(crate) fn set_locale(locale: impl AsRef<str>) {
217            *::r18::CURRENT_LOCALE
218                .get_or_init(|| ::std::sync::Mutex::new(None))
219                .lock()
220                .unwrap() = match ::r18::LanguageTag::parse_and_normalize(locale.as_ref()) {
221                    Ok(lang) => {
222                        match (lang.primary_language(), lang.region()) {
223                            #matches
224                            _ => None,
225                        }
226                    }
227                    Err(_) => None,
228                };
229        }
230    }
231}