1#![doc(html_root_url = "https://docs.rs/i18nify/0.2")]
47
48mod 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#[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 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 #[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 .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 .for_each(|(index,name)| {
550 assert_eq!(name.0.to_snake_case(),names[index])
551 });
552
553 Ok(())
554 }
555}