Skip to main content

i18nify_macro/
lib.rs

1//! Internationalization library based on code generation.
2//!
3//! By leveraging code generation we are able to prevent common bugs like typos in i18n keys,
4//! missing interpolations, or various mistakes between locales.
5//!
6//! It requires a directory with one JSON file per locale. Here is an example with English and
7//! Danish translations:
8//!
9//! ```json
10//! // tests/doc_locales/en.json
11//! {
12//!     "hello_world": "Hello, World!",
13//!     "greeting": "Hello {name}"
14//! }
15//!
16//! // tests/doc_locales/da.json
17//! {
18//!     "hello_world": "Hej, Verden!",
19//!     "greeting": "Hej {name}"
20//! }
21//! ```
22//!
23//! And in Rust:
24//!
25//! ```rust
26//! use demo::Internationalize;
27//! mod demo {
28//!     use i18nify::I18N;
29//!     #[derive(I18N)]
30//!     #[i18n(folder = "tests/doc_locales")]
31//!     pub struct DocLocale;
32//! }
33//! 
34//! fn main() {
35//!     // Based on the `Locale` enum type to retrieve internationalized text
36//!     let hello = demo::Locale::En.hello_world();
37//!     println!("{}",hello);// Hello, World!
38//!     
39//!     // Based on the `Internationalize` trait implemented with `DocLocale` to retrieve internationalized text
40//!     let greeting = DocLocale::da().greeting(Name("John"));
41//!     println!("{}",greeting);// Hej John
42//!}
43//! ```
44//! 
45
46#![doc(html_root_url = "https://docs.rs/i18nify/0.2")]
47
48// extern crate proc_macro;
49// extern crate proc_macro2;
50
51mod error;
52mod placeholder_parsing;
53mod schema;
54mod utils;
55
56use error::{Error, MissingKeysInLocale, Result};
57use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
58use placeholder_parsing::find_placeholders;
59use proc_macro2::{Ident, Span, TokenStream};
60use quote::quote;
61use rayon::prelude::*;
62use schema::{Config, I18nKey, Key, LocaleName, Placeholders, Translation, Translations};
63use std::{
64    collections::{HashMap, HashSet},
65    path::{Path, PathBuf},
66};
67use syn::{Attribute, DeriveInput, Expr, LitStr};
68use utils::{locale_name_from_translations_file_path, parse_translations_file};
69
70/// Generates the code for the `Locale` enum and such as the `Locale::hello_world()` methods.
71/// 
72/// ```rust
73/// 
74/// use i18nify::I18N;
75/// 
76/// #[derive(I18N)]
77/// #[i18n(folder = "tests/doc_locales")]
78/// pub struct DocLocale;
79/// 
80/// ```
81/// 
82/// `tests/doc_locales` is the folder where the translations are located.
83/// 
84/// ```javascript
85/// //tests/doc_locales/en.json
86/// {
87///     "hello_world": "Hello World!"
88/// }
89/// ```
90/// 
91/// ```javascript
92///  // tests/doc_locales/da.json
93/// {
94///     "hello_world": "Hej Verden!"
95/// }
96/// ```
97/// 
98/// ```rust
99/// use i18nify::{I18N, Locale};
100///
101/// fn main() {
102///     let locale = DocLocale::en();
103///     assert_eq!(locale.hello_world(), "Hello World!");
104/// 
105///     let locale = DocLocale::da();
106///     assert_eq!(locale.hello_world(), "Hej Verden!");
107///}
108#[proc_macro_derive(I18N, attributes(i18n))]
109pub fn try_i18n(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
110    let DeriveInput { attrs, ident, .. } = syn::parse_macro_input!(input);
111    
112    match try_i18n_with_folder2(ident, attrs) {
113        Ok(tokens) => tokens,
114        Err(err) => panic!("{}", err),
115    }
116}
117fn try_i18n_with_folder2(ident: Ident, attrs: Vec<Attribute>) -> Result<proc_macro::TokenStream> {
118    let mut folder = None;
119    let mut start = None;
120    let mut end = None;
121    
122    attrs
123        .iter()
124        .filter(|attribute| attribute.path().is_ident("i18n"))
125        .try_for_each(|attr| {
126            attr.parse_nested_meta(|meta| {
127                if meta.path.is_ident("folder") {
128                    folder = Some(meta.value()?.parse::<LitStr>()?);
129                } else if meta.path.is_ident("start") {
130                    start = Some(meta.value()?.parse::<LitStr>()?);
131                } else if meta.path.is_ident("end") {
132                    end = Some(meta.value()?.parse::<LitStr>()?);
133                } else {
134                    let _: Option<Expr> = meta.value().and_then(|v| v.parse()).ok();
135                }
136
137                Ok(())
138            })
139        })?;
140    let folder = folder.map(|x| x.value()).ok_or_else(|| {
141        syn::Error::new(
142            ident.span(),
143            "expected #[i18n(...)] attribute to be present when used with Locale derive trait",
144        )
145    })?;
146
147    let folder_path = shellexpand::full(&folder).map_err(|e| syn::Error::new(
148        ident.span(),
149        e.to_string(),
150    ))?.to_string();
151
152    let locale_folder = if Path::new(&folder_path).is_relative() {
153        let crate_root_path = Path::new(env!("CARGO_MANIFEST_DIR"));
154        crate_root_path.join(folder)
155    }else {
156        Path::new(&folder_path).to_path_buf()
157    };
158    
159    
160    if !locale_folder.is_dir() || !locale_folder.exists() {
161        return Err(error::Error::ProcMacroInput(syn::Error::new(
162            ident.span(),
163            "`folder` must be a relative path.",
164        )));
165    }
166
167    let start = start.map(|x| x.value()).unwrap_or("{".into());
168    let end = end.map(|x| x.value()).unwrap_or("}".into());
169    let config = Config {
170        open: start,
171        close: end,
172    };
173    
174    let file_paths = crate::utils::find_locale_files(locale_folder)?;
175    
176    let paths_and_contents = file_paths
177        .iter()
178        .map(|path| {
179            let contents = std::fs::read_to_string(path)?;
180            Ok((path, contents))
181        })
182        .collect::<Result<Vec<_>, Error>>()?;
183    
184    let translations = build_translations_from_files(&paths_and_contents, &config)?;
185    validate_translations(&translations)?;
186    
187    let locales = build_locale_names_from_files(&file_paths)?;
188    
189    let mut output = TokenStream::new();
190    gen_code(ident, locales, translations, &mut output);
191    // let syntax_tree: syn::File = syn::parse2(output.clone()).unwrap();
192    // let pretty = prettyplease::unparse(&syntax_tree);
193
194    Ok(output.into())
195}
196
197fn gen_code(
198    ident: Ident,
199    locales: Vec<LocaleName>,
200    translations: Translations,
201    out: &mut TokenStream,
202) {
203    gen_impl_internationalize(&locales, out);
204    gen_locale_enum(&locales, out);
205    gen_i18n_struct(translations, out);
206    out.extend(quote! {
207        impl Internationalize for #ident {}
208    })
209}
210
211
212
213fn gen_impl_internationalize(locales: &[LocaleName], out: &mut TokenStream) {
214    let variants = locales.iter().map(|key| ident(&key.0));
215    let fn_names = locales
216        .iter()
217        .map(|key| ident(&key.0.to_lower_camel_case()));
218
219    let methods = fn_names.zip(variants).map(|(fn_name, variant)| {
220        let fn_name = ident(&fn_name.to_string().to_snake_case());
221        let variant = ident(&variant.to_string().to_upper_camel_case());
222        quote! {
223            fn #fn_name(&self) -> Locale {
224                Locale::#variant
225            }
226        }
227    });
228    out.extend(quote! {
229        pub trait Internationalize {
230            #(#methods)*
231        }
232    });
233}
234
235fn gen_locale_enum(locales: &[LocaleName], out: &mut TokenStream) {
236    let variants = locales.iter().map(|key| {
237        let key = key.0.to_upper_camel_case();
238        ident(&key)
239    });
240
241    out.extend(quote! {
242        /// Locale enum generated by "i18nify"
243        #[derive(Copy, Clone, Debug)]
244        pub enum Locale {
245            #(#variants),*
246        }
247    });
248}
249
250fn gen_i18n_struct(translations: Translations, out: &mut TokenStream) {
251    let mut all_unique_placeholders = HashSet::<Ident>::new();
252
253    let methods = translations
254        .iter()
255        .map(|(key, translations)| {
256            let name = ident(&key.0);
257
258            let mut placeholders = translations
259                .iter()
260                .flat_map(|(_, (_, placeholders))| placeholders.0.iter().map(|p| ident(p)))
261                .collect::<HashSet<_>>()
262                .into_iter()
263                .collect::<Vec<_>>();
264            placeholders.sort();
265
266            for placeholder in &placeholders {
267                all_unique_placeholders.insert(placeholder.clone());
268            }
269
270            let args = placeholders.iter().map(|placeholder| {
271                let type_name = ident(&placeholder.to_string().to_upper_camel_case());
272                quote! { #placeholder: #type_name<'_> }
273            });
274
275            let match_arms = translations.iter().map(|(locale_name, (translation, _))| {
276                let locale_name = ident(&locale_name.0.to_upper_camel_case());
277                let translation = translation.0.to_string();
278
279                let body = if placeholders.is_empty() {
280                    quote! { format!(#translation) }
281                } else {
282                    let fields = placeholders.iter().filter_map(|placeholder| {
283                        let mut format_key = placeholder.to_string();
284                        format_key.truncate(format_key.len() - 1);
285
286                        let placehoder_with_open_close = format!(
287                            "{open}{placeholder}{close}",
288                            open = "{",
289                            placeholder = format_key,
290                            close = "}",
291                        );
292                        if translation.contains(&placehoder_with_open_close) {
293                            let format_key = ident(&format_key);
294                            Some(quote! { #format_key = #placeholder.0 })
295                        } else {
296                            None
297                        }
298                    });
299                    quote! { format!(#translation, #(#fields),*) }
300                };
301
302                quote! {
303                    Locale::#locale_name => #body
304                }
305            });
306            quote! {
307                #[allow(missing_docs)]
308                pub fn #name(self, #(#args),*) -> String {
309                    match self {
310                        #(#match_arms),*
311                    }
312                }
313            }
314        })
315        .collect::<Vec<_>>();
316
317    let placeholder_newtypes = all_unique_placeholders.into_iter().map(|placeholder| {
318        let placeholder = ident(&placeholder.to_string().to_upper_camel_case());
319        quote! {
320            #[allow(missing_docs)]
321            pub struct #placeholder<'a>(pub &'a str);
322        }
323    });
324
325    out.extend(quote! {
326        #(#placeholder_newtypes)*
327
328        impl Locale {
329            #(#methods)*
330        }
331    });
332}
333
334fn ident(name: &str) -> Ident {
335    Ident::new(name, Span::call_site())
336}
337
338fn build_translations_from_files(
339    paths_and_contents: &[(&PathBuf, String)],
340    config: &Config,
341) -> Result<Translations> {
342    
343    let keys_per_locale = paths_and_contents
344        .iter()
345        .map(|(path, contents)| {
346            let locale_name = locale_name_from_translations_file_path(path)?;
347            
348            let map = parse_translations_file(contents)?;
349            
350            let keys_in_file = build_keys_from_json(map, config, &locale_name)?;
351
352            let locale_and_keys = keys_in_file
353                .into_iter()
354                .map(|key| (locale_name.clone(), key))
355                .collect::<Vec<(LocaleName, I18nKey)>>();
356            Ok(locale_and_keys)
357        })
358        .collect::<Result<Vec<_>, Error>>()?;
359
360    let keys_per_locale: HashMap<(LocaleName, Key), (Translation, Placeholders)> = keys_per_locale
361        .into_iter()
362        .flatten()
363        .map(|(locale, key)| ((locale, key.key), (key.translation, key.placeholders)))
364        .collect();
365
366    let number_of_keys_per_locale = keys_per_locale.len() / paths_and_contents.len();
367    let mut acc: Translations = HashMap::with_capacity(number_of_keys_per_locale);
368
369    for ((locale_name, key), (translation, placeholders)) in keys_per_locale {
370        let entry = acc
371            .entry(key)
372            .or_insert_with(|| HashMap::with_capacity(paths_and_contents.len()));
373        entry.insert(locale_name, (translation, placeholders));
374    }
375
376    Ok(acc)
377}
378
379fn build_locale_names_from_files(file_paths: &[PathBuf]) -> Result<Vec<LocaleName>> {
380    file_paths
381        .iter()
382        .map(locale_name_from_translations_file_path)
383        .collect()
384}
385
386fn validate_translations(translations: &Translations) -> Result<()> {
387    let all_keys = all_keys(translations);
388    let keys_per_locale = keys_per_locale(translations);
389
390    let mut errors = Vec::new();
391    for (locale_name, keys) in keys_per_locale {
392        let keys_missing = all_keys.difference(&keys).collect::<HashSet<_>>();
393        if !keys_missing.is_empty() {
394            let keys = keys_missing.iter().map(|key| (**key).clone()).collect();
395
396            errors.push(MissingKeysInLocale {
397                locale_name: locale_name.clone(),
398                keys,
399            });
400        }
401    }
402
403    if errors.is_empty() {
404        Ok(())
405    } else {
406        Err(Error::MissingKeysInLocale(errors))
407    }
408}
409
410fn all_keys(translations: &Translations) -> HashSet<&Key> {
411    translations.keys().collect()
412}
413
414fn keys_per_locale(translations: &Translations) -> HashMap<&LocaleName, HashSet<&Key>> {
415    let mut acc = HashMap::new();
416
417    for (key, translations_for_key) in translations {
418        for (locale_name, (_translation, _placeholders)) in translations_for_key {
419            acc.entry(locale_name)
420                .or_insert_with(HashSet::new)
421                .insert(key);
422        }
423    }
424
425    acc
426}
427
428fn build_keys_from_json(
429    map: HashMap<String, String>,
430    config: &Config,
431    locale_name: &LocaleName,
432) -> Result<Vec<I18nKey>> {
433    map.into_par_iter()
434        .map(|(key, value)| {
435            let placeholders = find_placeholders(&value, &config.open, &config.close, locale_name)?;
436            let value = value.replace(&config.open, "{").replace(&config.close, "}");
437            let key = key.replace(".", "_").replace("-", "_");
438
439            Ok(I18nKey {
440                key: Key(key),
441                translation: Translation(value),
442                placeholders: Placeholders(placeholders),
443            })
444        })
445        .collect()
446}
447
448#[allow(
449    unused_imports,
450    dead_code,
451    unused_variables,
452    unknown_lints,
453    missing_docs,
454    unused_must_use
455)]
456#[cfg(test)]
457mod test {
458    use std::path::Path;
459
460    #[allow(unused_imports)]
461    use super::*;
462
463    #[test]
464    #[cfg(feature="json")]
465    fn test_reading_files() {
466        let input = "tests/locales";
467        let crate_root_path = Path::new(env!("CARGO_MANIFEST_DIR"));
468        let locale_path = crate_root_path.join(input).join(PathBuf::from("en.json"));
469
470        let contents = std::fs::read_to_string(&locale_path).unwrap();
471        let map = parse_translations_file(&contents).unwrap();
472        let mut keys =
473            build_keys_from_json(map, &Config::default(), &LocaleName::new("test")).unwrap();
474        keys.sort_by_key(|key| key.key.0.clone());
475
476        assert_eq!(keys[0].key.0, "duplicate_placeholders");
477        assert_eq!(keys[0].translation.0, "Hey {name}. Is your name {name}?");
478        assert_eq!(to_vec(keys[0].placeholders.0.clone()), vec!["name_"]);
479    }
480
481    #[test]
482    #[cfg(feature="json")]
483    fn test_finding_locale_names() {
484        let input = "tests/locales";
485        let crate_root_path = Path::new(env!("CARGO_MANIFEST_DIR"));
486        let locale_path = crate_root_path.join(input).join(PathBuf::from("en.json"));
487
488        let locale_name = locale_name_from_translations_file_path(&locale_path).unwrap();
489
490        assert_eq!(locale_name.0, "En");
491    }
492
493    #[test]
494    fn ui() {
495        let t = trybuild::TestCases::new();
496        t.compile_fail("tests/compile_fail/*.rs");
497    }
498
499    #[test]
500    fn test_html_root_url() {
501        version_sync::assert_html_root_url_updated!("src/lib.rs");
502    }
503
504    fn to_vec<T: std::hash::Hash + Eq>(set: HashSet<T>) -> Vec<T> {
505        set.into_iter().collect()
506    }
507
508    #[test]
509    #[cfg(feature="json")]
510    fn test_build_locale_names_from_files()->Result<(), Box<dyn std::error::Error>> {
511
512        let file_paths = &[
513            ("zh_cn",PathBuf::from("tests/zh_locales/zh_CN.json")),
514           ("en",PathBuf::from("tests/zh_locales/en.json")),
515        ];
516
517        let paths = file_paths.iter().map(|f| f.1.clone()).collect::<Vec<_>>();
518        let names = file_paths.iter().map(|f| f.0.to_string()).collect::<Vec<_>>();
519
520        let locales = super::build_locale_names_from_files(&paths).unwrap();
521        locales
522        .iter()
523        .enumerate()
524        // .map(|key| ident(&key.0.to_lower_camel_case())).collect::<Vec<_>>();
525        .for_each(|(index,name)| {
526            assert_eq!(name.0.to_snake_case(),names[index])
527        });
528
529        Ok(())
530    }
531
532    #[test]
533    #[cfg(feature="toml")]
534    fn test_build_locale_names_from_files()->Result<(), Box<dyn std::error::Error>> {
535
536        let file_paths = &[
537            ("zh_cn",PathBuf::from("tests/toml_locales/zh_CN.toml")),
538           ("en",PathBuf::from("tests/toml_locales/en.toml")),
539        ];
540
541        let paths = file_paths.iter().map(|f| f.1.clone()).collect::<Vec<_>>();
542        let names = file_paths.iter().map(|f| f.0.to_string()).collect::<Vec<_>>();
543
544        let locales = super::build_locale_names_from_files(&paths).unwrap();
545        locales
546        .iter()
547        .enumerate()
548        // .map(|key| ident(&key.0.to_lower_camel_case())).collect::<Vec<_>>();
549        .for_each(|(index,name)| {
550            assert_eq!(name.0.to_snake_case(),names[index])
551        });
552
553        Ok(())
554    }
555}