tiny_i18n_macro/
lib.rs

1mod code_generation;
2mod utils;
3
4use std::{collections::HashMap, fs::File, io::Read, path::PathBuf};
5
6use code_generation::generate_functions;
7use proc_macro::TokenStream;
8use syn::{parse_macro_input, DeriveInput, LitStr};
9use utils::delete_invalid_rust_identifiers_characters;
10
11///This macro generates a function per translation key inside the specified struct.
12///Example :
13///```
14///#[tiny_i18n::i18n("path_of_the_translations_directory")] //defaults to `i18n` if called without argument
15///pub(crate) struct I18n;
16///
17///fn main() {
18///    // Each translation key generates a function (every non-ASCII letter, number or underscore are removed)
19///    println!("{}", I18n::hello_world("en-us")); //the first argument is the language, the following are the arguments, if any
20///}
21///```
22#[proc_macro_attribute]
23pub fn i18n(attr_args: TokenStream, stream: TokenStream) -> TokenStream {
24    let locales_dir = syn::parse::<LitStr>(attr_args)
25        .map(|value| value.value())
26        .unwrap_or("i18n".to_string());
27
28    let base_stream: proc_macro2::TokenStream = stream.clone().into();
29
30    let struct_name = parse_macro_input!(stream as DeriveInput).ident;
31
32    let translations = match read_languages_directory_to_hashmap(&locales_dir) {
33        Ok(translations) => translations,
34        Err(e) => {
35            return quote::quote! {
36                compile_error!(#e)
37            }
38            .into()
39        }
40    };
41
42    let message_id_macros: Vec<_> = translations.into_iter().map(generate_functions).collect();
43
44    quote::quote! {
45        #base_stream
46
47        impl #struct_name {
48            #(#message_id_macros)*
49        }
50    }
51    .into()
52}
53
54/// Read the languages directory and parse it into an HashMap where the key is the ID of the translation and the value is another HashMap where the key is the language and the value is the translation for that language
55fn read_languages_directory_to_hashmap(
56    dir: &str,
57) -> Result<HashMap<String, HashMap<String, Vec<TokenType>>>, String> {
58    let root_dir = match std::fs::read_dir(dir) {
59        Ok(dir) => dir,
60        Err(e) => return Err(format!("Error reading languages directory : {e}")),
61    };
62    let mut languages_wrongly_sorted = HashMap::new();
63
64    for language_dir in root_dir {
65        let language_dir =
66            language_dir.map_err(|e| format!("Error reading language directory : {e}"))?;
67        let lang = language_dir.file_name();
68        let lang = lang.to_str().ok_or(format!(
69            "Non Unicode name of folder {}",
70            lang.to_string_lossy()
71        ))?;
72        let metadata = language_dir
73            .metadata()
74            .map_err(|e| format!("Error getting lang directory `{lang}` metadata : {e}"))?;
75        if !metadata.is_dir() {
76            return Err(format!(
77                "Unexpected file `{lang}` in the languages directory, exiting."
78            ));
79        }
80        let _ = metadata;
81        let language_translations = read_language_directory_to_hashmap(language_dir.path())?;
82        languages_wrongly_sorted.insert(lang.to_lowercase(), language_translations);
83    }
84
85    let translations = organize_hashmap_properly(languages_wrongly_sorted);
86
87    Ok(translations)
88}
89
90/// Read a language directory and parse it into an HashMap where the key is the ID of the translation and the value is the translation
91fn read_language_directory_to_hashmap(
92    dir: PathBuf,
93) -> Result<HashMap<String, Vec<TokenType>>, String> {
94    let dir = std::fs::read_dir(dir.clone())
95        .map_err(|e| format!("Error reading `{}` language directory : {e}", dir.display()))?;
96    let mut translations = HashMap::new();
97
98    for file in dir {
99        let file = file
100            .map_err(|e| format!("Error reading a translation file in the lang directory : {e}"))?;
101        let metadata = file.metadata().map_err(|e| {
102            format!(
103                "Error reading {} translation file metadata : {e}",
104                file.path().display()
105            )
106        })?;
107        if !metadata.is_file() {
108            let language_hashmap = read_language_directory_to_hashmap(file.path())?;
109            for (file_name, tokens) in language_hashmap.into_iter() {
110                if translations.insert(file_name.clone(), tokens).is_some() {
111                    return Err(format!("Dupplicate translation key `{file_name}`"));
112                }
113            }
114        }
115        let _ = metadata;
116        let file_name = file.file_name();
117        let file_name = file_name.to_str().ok_or(format!(
118            "Non Unicode name of file {}",
119            file_name.to_string_lossy()
120        ))?;
121        let file_name = file_name
122            .split_once('.')
123            .map(|name| name.0)
124            .unwrap_or(file_name);
125        let file_path = file.path();
126        let mut file = File::open(file.path()).map_err(|e| {
127            format!(
128                "Error opening {} translation file : {e}",
129                file.path().display()
130            )
131        })?;
132        let mut file_content = String::new();
133        file.read_to_string(&mut file_content)
134            .map_err(|e| format!("Error reading {} translation file {e}", file_path.display()))?;
135        drop(file);
136
137        let file_name = delete_invalid_rust_identifiers_characters(file_name)?;
138
139        let tokens = parse_arguments(&file_content)?;
140
141        if translations.insert(file_name.clone(), tokens).is_some() {
142            return Err(format!("Duplicate translation key `{file_name}`"));
143        }
144    }
145
146    Ok(translations)
147}
148
149///Map `HashMap<lang, HashMap<translation_id, translation>>` to `HashMap<translation_id, HashMap<lang, translation>>`
150///
151///*Note : only the translations available for the fallback language (en-US for now) are took into account*
152fn organize_hashmap_properly(
153    mut languages_wrongly_sorted: HashMap<String, HashMap<String, Vec<TokenType>>>,
154) -> HashMap<String, HashMap<String, Vec<TokenType>>> {
155    let Some(en_translations) = languages_wrongly_sorted.remove("en-us") else {
156        panic!("Missing en-US translations");
157    };
158
159    let mut translations = HashMap::new();
160
161    for (translation_id, translation_value) in en_translations {
162        let mut languages = HashMap::new();
163        languages.insert("en-us".to_string(), translation_value);
164        for (lang, lang_translations) in &mut languages_wrongly_sorted {
165            if let Some(translation) = lang_translations.remove(&translation_id) {
166                languages.insert(lang.to_string(), translation);
167            }
168        }
169        translations.insert(translation_id, languages);
170    }
171
172    translations
173}
174
175#[derive(Clone)]
176enum TokenType {
177    String(String),
178    Argument(String),
179}
180
181fn parse_arguments(translation: &str) -> Result<Vec<TokenType>, String> {
182    let mut tokens = Vec::new();
183
184    let mut iter = translation.char_indices().into_iter().peekable();
185
186    let mut last_string_token = String::new();
187
188    loop {
189        let Some((i, c)) = iter.next() else {
190            break;
191        };
192        match c {
193            '$' => {
194                if !last_string_token.is_empty() {
195                    tokens.push(TokenType::String(std::mem::take(&mut last_string_token)));
196                }
197
198                let mut argument_name = String::new();
199                loop {
200                    let Some((i, next_token)) = iter.peek() else {
201                        break;
202                    };
203                    let (i, next_token) = (*i, *next_token);
204                    if argument_name.is_empty() {
205                        if !next_token.is_ascii_alphabetic() && next_token != '_' {
206                            return Err(format!("Expected alphabetic ASCII character or underscore, found {next_token} at index {i}"));
207                        }
208                    } else {
209                        if !next_token.is_ascii_alphanumeric() && next_token != '_' {
210                            break;
211                        }
212                    }
213                    iter.next();
214                    argument_name.push(next_token.to_ascii_lowercase());
215                }
216
217                if argument_name.is_empty() {
218                    return Err(format!("Expected name of argument at index {i}, got EOF"));
219                }
220                tokens.push(TokenType::Argument(argument_name));
221            }
222            '\\' => {
223                let Some((_, c_to_escape)) = iter.next() else {
224                    return Err(format!(
225                        "Expected character to escape at index {i}, got EOF"
226                    ));
227                };
228                if c_to_escape != '$' {
229                    last_string_token.push('\\');
230                }
231                last_string_token.push(c_to_escape);
232            }
233            c => last_string_token.push(c),
234        }
235    }
236
237    if !last_string_token.is_empty() {
238        tokens.push(TokenType::String(last_string_token));
239    }
240
241    Ok(tokens)
242}