Add compile time key check
This commit is contained in:
parent
67e3b1eeec
commit
fa5547181a
|
@ -8,8 +8,10 @@ keywords = ["i18n", "internationalization"]
|
||||||
license = "MIT/Apache-2.0"
|
license = "MIT/Apache-2.0"
|
||||||
repository = "https://github.com/terry90/internationalization-rs"
|
repository = "https://github.com/terry90/internationalization-rs"
|
||||||
homepage = "https://github.com/terry90/internationalization-rs"
|
homepage = "https://github.com/terry90/internationalization-rs"
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
[dependencies]
|
[build-dependencies]
|
||||||
lazy_static = "1.4.0"
|
|
||||||
glob = "0.3.0"
|
glob = "0.3.0"
|
||||||
serde_json = "1.0.40"
|
quote = "1.0.2"
|
||||||
|
serde_json = "1.0.41"
|
||||||
|
proc-macro2 = "1.0"
|
64
README.md
64
README.md
|
@ -1,17 +1,69 @@
|
||||||
# Internationalization
|
# 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)
|
> API documentation [https://crates.io/crates/internationalization](https://crates.io/crates/internationalization)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
Have a `locales/` folder somewhere in your app, root, src, anywhere. with `.json` files, nested in folders or not.
|
||||||
use internationalization::{init_i18n, t};
|
It uses a glob pattern: `**/locales/**/*.json` to match your translation files.
|
||||||
fn main() {
|
|
||||||
init_i18n!("locales/*.json", "fr", "en");
|
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);
|
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.
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"key.test": {
|
||||||
|
"en": "This is a test",
|
||||||
|
"fr": "C'est un test"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
88
src/lib.rs
88
src/lib.rs
|
@ -1,30 +1,86 @@
|
||||||
//! # Internationalization
|
//! # 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)
|
//! > API documentation [https://crates.io/crates/internationalization](https://crates.io/crates/internationalization)
|
||||||
|
|
||||||
//! ## Usage
|
//! ## 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() {
|
//! fn main() {
|
||||||
//! init_i18n!("locales/*.json", "fr", "en");
|
//! 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);
|
//! assert_eq!("You are not allowed to do this", res);
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod i18n;
|
//! ## Installation
|
||||||
pub use i18n::t;
|
|
||||||
|
|
||||||
#[macro_export]
|
//! Internationalization is available on [crates.io](https://crates.io/crates/internationalization), include it in your `Cargo.toml`:
|
||||||
macro_rules! init_i18n {
|
|
||||||
( $path:expr, $( $lang:expr ),* ) => {
|
//! ```toml
|
||||||
use internationalization::i18n::{load_i18n, read_files};
|
//! [dependencies]
|
||||||
for content in read_files($path) {
|
//! internationalization = "0.0.2"
|
||||||
load_i18n(content)
|
//! ```
|
||||||
|
|
||||||
|
//! 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");
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue