Add compile time key check

This commit is contained in:
Terry Raimondo 2019-10-09 02:37:41 +02:00
parent 67e3b1eeec
commit fa5547181a
6 changed files with 224 additions and 89 deletions

View File

@ -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"

View File

@ -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.

82
build.rs Normal file
View File

@ -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<Key, HashMap<Locale, Value>>;
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<String, HashMap<String, String>> =
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::<TokenStream>::new();
for (key, trs) in translations {
let mut langs = Vec::<TokenStream>::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);
}

6
locales/test.json Normal file
View File

@ -0,0 +1,6 @@
{
"key.test": {
"en": "This is a test",
"fr": "C'est un test"
}
}

View File

@ -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<HashMap<Key, HashMap<Locale, Value>>> = RwLock::new(HashMap::new());
}
pub fn read_files(pattern: &str) -> Vec<String> {
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<String, HashMap<String, String>> =
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),
}
}

View File

@ -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");
}
}