From fa5547181adca916147b901fd0c8500b4353a6e6 Mon Sep 17 00:00:00 2001 From: Terry Raimondo Date: Wed, 9 Oct 2019 02:37:41 +0200 Subject: [PATCH] Add compile time key check --- Cargo.toml | 8 +++-- README.md | 64 +++++++++++++++++++++++++++++---- build.rs | 82 ++++++++++++++++++++++++++++++++++++++++++ locales/test.json | 6 ++++ src/i18n/mod.rs | 63 --------------------------------- src/lib.rs | 90 ++++++++++++++++++++++++++++++++++++++--------- 6 files changed, 224 insertions(+), 89 deletions(-) create mode 100644 build.rs create mode 100644 locales/test.json delete mode 100644 src/i18n/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 00e131c..c98d070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,10 @@ keywords = ["i18n", "internationalization"] license = "MIT/Apache-2.0" repository = "https://github.com/terry90/internationalization-rs" homepage = "https://github.com/terry90/internationalization-rs" +build = "build.rs" -[dependencies] -lazy_static = "1.4.0" +[build-dependencies] glob = "0.3.0" -serde_json = "1.0.40" +quote = "1.0.2" +serde_json = "1.0.41" +proc-macro2 = "1.0" \ No newline at end of file diff --git a/README.md b/README.md index b99f585..d6c06c5 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,69 @@ # Internationalization -An simple i18n implementation in Rust. +![LICENSE](https://img.shields.io/crates/l/internationalization) +[![Crates.io Version](https://img.shields.io/crates/v/internationalization.svg)](https://crates.io/crates/internationalization) +[![Coverage Status](https://coveralls.io/repos/github/terry90/internationalization-rs/badge.svg?branch=master)](https://coveralls.io/github/terry90/internationalization-rs?branch=master) + +An simple compile time i18n implementation in Rust. +It throws a compilation error if the translation key is not present, but since the `lang` argument is dynamic it will panic if the language has not been added for the matching key. > API documentation [https://crates.io/crates/internationalization](https://crates.io/crates/internationalization) ## Usage -```rust -use internationalization::{init_i18n, t}; -fn main() { - init_i18n!("locales/*.json", "fr", "en"); +Have a `locales/` folder somewhere in your app, root, src, anywhere. with `.json` files, nested in folders or not. +It uses a glob pattern: `**/locales/**/*.json` to match your translation files. + +the files must look like this: + +```json +{ + "err.answer.all": { + "fr": "Échec lors de la récupération des réponses", + "en": "Failed to retrieve answers" + }, + "err.answer.delete.failed": { + "fr": "Échec lors de la suppression de la réponse", + "en": "Failed to delete answer" + } +} +``` + +Any number of languages can be added, but you should provide them for everything since it will panic if a language is not found when queried for a key. + +In your app, jsut call the `t!` macro + +```rust +fn main() { + let lang = "en"; + let res = t!("err.not_allowed", lang); - let res = t("err.not_allowed"); assert_eq!("You are not allowed to do this", res); } ``` + +## Installation + +Internationalization is available on [crates.io](https://crates.io/crates/internationalization), include it in your `Cargo.toml`: + +```toml +[dependencies] +internationalization = "0.0.2" +``` + +Then include it in your code like this: + +```rust +#[macro_use] +extern crate internationalization; +``` + +Or use the macro where you want to use it: + +```rust +use internationalization::t; +``` + +## Note + +Internationalization will not work if no `PWD` env var is set at compile time. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..aa20f26 --- /dev/null +++ b/build.rs @@ -0,0 +1,82 @@ +use glob::glob; +use proc_macro2::TokenStream; +use quote::quote; +use std::collections::HashMap; +use std::fs::File; +use std::io::prelude::*; + +type Key = String; +type Locale = String; +type Value = String; +type Translations = HashMap>; + +fn read_locales() -> Translations { + let mut translations: Translations = HashMap::new(); + + let build_directory = std::env::var("PWD").unwrap(); + let locales = format!("{}/**/locales/**/*.json", build_directory); + println!("Reading {}", &locales); + + for entry in glob(&locales).expect("Failed to read glob pattern") { + let entry = entry.unwrap(); + println!("cargo:rerun-if-changed={}", entry.display()); + let file = File::open(entry).expect("Failed to open the file"); + let mut reader = std::io::BufReader::new(file); + let mut content = String::new(); + reader + .read_to_string(&mut content) + .expect("Failed to read the file"); + let res: HashMap> = + serde_json::from_str(&content).expect("Cannot parse locale file"); + translations.extend(res); + } + translations +} + +fn generate_code(translations: Translations) -> proc_macro2::TokenStream { + let mut branches = Vec::::new(); + + for (key, trs) in translations { + let mut langs = Vec::::new(); + for (lang, tr) in trs { + let l = quote! { + #lang => #tr, + }; + langs.push(l) + } + let branch = quote! { + (#key, $lang:expr) => { + match $lang.as_ref() { + #(#langs)* + e => panic!("Missing language: {}", e) + } + }; + }; + branches.push(branch); + } + + quote! { + #[macro_export] + macro_rules! t { + #(#branches)* + ($key:expr, $lang:expr) => { + compile_error!("Missing translation"); + } + } + } +} + +fn write_code(code: TokenStream) { + let dest = std::env::var("OUT_DIR").unwrap(); + let mut output = File::create(&std::path::Path::new(&dest).join("i18n.rs")).unwrap(); + output + .write(code.to_string().as_bytes()) + .expect("Cannot write generated i18n code"); +} + +fn main() { + let translations = read_locales(); + let code = generate_code(translations); + println!("{}", &code); + write_code(code); +} diff --git a/locales/test.json b/locales/test.json new file mode 100644 index 0000000..dc9ae57 --- /dev/null +++ b/locales/test.json @@ -0,0 +1,6 @@ +{ + "key.test": { + "en": "This is a test", + "fr": "C'est un test" + } +} diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs deleted file mode 100644 index 4685444..0000000 --- a/src/i18n/mod.rs +++ /dev/null @@ -1,63 +0,0 @@ -use glob::glob; -use lazy_static::lazy_static; -use std::collections::HashMap; -use std::fs::File; -use std::io::BufReader; -use std::io::Read; -use std::sync::RwLock; - -type Key = String; -type Locale = String; -type Value = String; - -lazy_static! { - pub static ref TR: RwLock>> = RwLock::new(HashMap::new()); -} - -pub fn read_files(pattern: &str) -> Vec { - let mut contents = Vec::new(); - for entry in glob(pattern).expect("Failed to read glob pattern") { - let file = File::open(entry.unwrap()).expect("Failed to open the file"); - let mut reader = BufReader::new(file); - let mut content = String::new(); - reader - .read_to_string(&mut content) - .expect("Failed to read the file"); - contents.push(content); - } - contents -} - -pub fn load_i18n(content: String) { - let res: HashMap> = - serde_json::from_str(&content).expect("Cannot parse I18n file"); - TR.write().unwrap().extend(res); -} - -/// Translates by key -/// -/// # Panics -/// -/// If a key is missing, the code will panic -/// If a locale is not present for the key, ths will also panic -/// -/// # Example -/// ```no-run -/// use internationalization::t; -/// -/// fn main() { -/// init_i18n!("locales/*.json", "fr", "en"); -/// -/// let res = t("err.not_allowed"); -/// assert_eq!("You are not allowed to do this", res); -/// } -/// ``` -pub fn t(key: &str, locale: &str) -> String { - match TR.read().unwrap().get(key) { - Some(trs) => match trs.get(locale) { - Some(value) => value.to_owned(), - None => panic!("Missing language ({}) for key: {}", locale, key), - }, - None => panic!("Missing key: {}", key), - } -} diff --git a/src/lib.rs b/src/lib.rs index a82fdca..c40852a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,30 +1,86 @@ //! # Internationalization -//! An simple i18n implementation in Rust. + +//! [![Crates.io Version](https://img.shields.io/crates/v/internationalization.svg)](https://crates.io/crates/internationalization) +//! ![LICENSE](https://img.shields.io/crates/l/internationalization) +//! [![Coverage Status](https://coveralls.io/repos/github/terry90/internationalization-rs/badge.svg?branch=master)](https://coveralls.io/github/terry90/internationalization-rs?branch=master) +//! +//! An simple compile time i18n implementation in Rust. +//! It throws a compilation error if the translation key is not present, but since the `lang` argument is dynamic it will panic if the language has not been added for the matching key. //! > API documentation [https://crates.io/crates/internationalization](https://crates.io/crates/internationalization) //! ## Usage -//! + +//! Have a `locales/` folder somewhere in your app, root, src, anywhere. with `.json` files, nested in folders or not. +//! It uses a glob pattern: `**/locales/**/*.json` to match your translation files. + +//! the files must look like this: + +//! ```json +//! { +//! "err.answer.all": { +//! "fr": "Échec lors de la récupération des réponses", +//! "en": "Failed to retrieve answers" +//! }, +//! "err.answer.delete.failed": { +//! "fr": "Échec lors de la suppression de la réponse", +//! "en": "Failed to delete answer" +//! } +//! } //! ``` -//! use internationalization::{init_i18n, t}; -//! + +//! Any number of languages can be added, but you should provide them for everything since it will panic if a language is not found when queried for a key. + +//! In your app, jsut call the `t!` macro + +//! ```rust //! fn main() { -//! init_i18n!("locales/*.json", "fr", "en"); -//! -//! let res = t("err.not_allowed"); +//! let lang = "en"; +//! let res = t!("err.not_allowed", lang); + //! assert_eq!("You are not allowed to do this", res); //! } //! ``` -pub mod i18n; -pub use i18n::t; +//! ## Installation -#[macro_export] -macro_rules! init_i18n { - ( $path:expr, $( $lang:expr ),* ) => { - use internationalization::i18n::{load_i18n, read_files}; - for content in read_files($path) { - load_i18n(content) - } - }; +//! Internationalization is available on [crates.io](https://crates.io/crates/internationalization), include it in your `Cargo.toml`: + +//! ```toml +//! [dependencies] +//! internationalization = "0.0.2" +//! ``` + +//! Then include it in your code like this: + +//! ```rust +//! #[macro_use] +//! extern crate internationalization; +//! ``` + +//! Or use the macro where you want to use it: + +//! ```rust +//! use internationalization::t; +//! ``` + +//! ## Note + +//! Internationalization will not work if no `PWD` env var is set at compile time. + +include!(concat!(env!("OUT_DIR"), "/i18n.rs")); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_translates() { + assert_eq!(t!("key.test", "en"), "This is a test"); + } + #[test] + #[should_panic] + fn it_fails_to_translate() { + assert_eq!(t!("key.test", "es"), "Hola"); + } }