rocket_i18n/src/lib.rs
2018-10-25 22:19:54 +01:00

304 lines
9.1 KiB
Rust

//! # Rocket I18N
//!
//! A crate to help you internationalize your Rocket applications.
//!
//! ## Features
//!
//! - Build helpers (with the `build` feature enabled), to update and compile PO files.
//! - Select the correct locale for each request
//! - Provides a macro to internationalize any string.
//!
//! ## Usage
//!
//! First add it to your `Cargo.toml` (you have to use the git version, because we can't publish the latest version on [https://crates.io](crates.io) as it depends on the `master` branch of Rocket):
//!
//! ```toml
//! [dependencies.rocket_i18n]
//! git = "https://github.com/BaptisteGelez/rocket_i18n"
//! rev = "<LATEST COMMIT>"
//! ```
//!
//! Then, in your `main.rs`:
//!
//! ```rust,ignore
//! extern crate rocket;
//! #[macro_use]
//! extern crate rocket_i18n;
//!
//! fn main() {
//! rocket::ignite()
//! // Make Rocket manage your translations.
//! .manage(rocket_i18n::i18n(vec![ "en", "fr", "de", "ja" ]));
//! // Register routes, etc
//! }
//! ```
//!
//! Then in all your requests you'll be able to use the `i18n` macro to translate anything.
//! It takes a `gettext::Catalog` and a string to translate as argument.
//!
//! ```rust,ignore
//! # #[macro_use] extern crate rocket_i18n;
//!
//! use rocket_i18n::I18n;
//!
//! #[get("/")]
//! fn route(i18n: I18n) -> &str {
//! i18n!(i18n.catalog, "Hello, world!")
//! }
//! ```
//!
//! For strings that may have a plural form, just add the plural and the number of element to the
//! arguments
//!
//! ```rust,ignore
//! i18n!(i18n.catalog, "One new message", "{0} new messages", 42);
//! ```
//!
//! Any extra argument, after a `;`, will be used for formatting.
//!
//! ```rust,ignore
//! let user_name = "Alex";
//! i18n!(i18n.catalog, "Hello {0}!"; user_name);
//! ```
//!
//! When using it with plural, `{0}` will be the number of elements, and other arguments will start
//! at `{1}`.
//!
//! Because of its design, rocket_i18n is only compatible with askama. You can use
//! the `t` macro in your templates, as long as they have a field called `catalog` to
//! store your catalog.
//!
//! ### Editing the POT
//!
//! For those strings to be translatable you should also add them to the `po/YOUR_DOMAIN.pot` file. To add a simple message, just do:
//!
//! ```po
//! msgid "Hello, world" # The string you used with your filter
//! msgstr "" # Always empty
//! ```
//!
//! For plural forms, the syntax is a bit different:
//!
//! ```po
//! msgid "You have one new notification" # The singular form
//! msgid_plural "You have {{ count }} new notifications" # The plural one
//! msgstr[0] ""
//! msgstr[1] ""
//! ```
//!
extern crate gettext;
extern crate rocket;
pub use gettext::*;
use rocket::{
http::Status,
request::{self, FromRequest},
Outcome, Request, State,
};
use std::fs;
const ACCEPT_LANG: &'static str = "Accept-Language";
/// A request guard to get the right translation catalog for the current request
pub struct I18n<'a> {
pub catalog: Catalog<'a>,
}
type Translations<'a> = Vec<(&'static str, Catalog<'a>)>;
pub fn i18n(lang: Vec<&'static str>) -> Translations {
lang.iter().fold(Vec::new(), |mut trans, l| {
let mo_file =
fs::File::open(format!("translations/{}.mo", l)).expect("Couldn't open catalog");
let cat = Catalog::parse(mo_file).expect("Error while loading catalog");
trans.push((l, cat));
trans
})
}
impl<'a, 'r> FromRequest<'a, 'r> for I18n<'r> {
type Error = ();
fn from_request(req: &'a Request<'r>) -> request::Outcome<I18n<'r>, ()> {
let langs = &*req
.guard::<State<Translations>>()
.expect("Couldn't retrieve translations because they are not managed by Rocket.");
let lang = req
.headers()
.get_one(ACCEPT_LANG)
.unwrap_or("en")
.split(",")
.filter_map(|lang| lang
// Get the locale, not the country code
.split(|c| c == '-' || c == ';')
.nth(0))
// Get the first requested locale we support
.find(|lang| langs.iter().any(|l| l.0 == &lang.to_string()))
.unwrap_or("en");
match langs.iter().find(|l| l.0 == lang) {
Some(catalog) => Outcome::Success(I18n {
catalog: catalog.1.clone(),
}),
None => Outcome::Failure((Status::InternalServerError, ())),
}
}
}
#[cfg(feature = "build")]
pub fn update_po(domain: &str) {
let pot_path = Path::new("po").join(format!("{}.pot", domain));
for lang in get_locales() {
let po_path = Path::new("po").join(format!("{}.po", lang.clone()));
if po_path.exists() && po_path.is_file() {
println!("Updating {}", lang.clone());
// Update it
Command::new("msgmerge")
.arg("-U")
.arg(po_path.to_str().unwrap())
.arg(pot_path.to_str().unwrap())
.spawn()
.expect("Couldn't update PO file");
} else {
println!("Creating {}", lang.clone());
// Create it from the template
Command::new("msginit")
.arg(format!("--input={}", pot_path.to_str().unwrap()))
.arg(format!("--output-file={}", po_path.to_str().unwrap()))
.arg("-l")
.arg(lang)
.arg("--no-translator")
.spawn()
.expect("Couldn't init PO file");
}
}
}
/// Transforms all the .po files in the `po` directory of your project
#[cfg(feature = "build")]
fn compile_po() {
for lang in get_locales() {
let po_path = Path::new("po").join(format!("{}.po", lang.clone()));
let mo_dir = Path::new("translations")
.join(lang.clone())
.join("LC_MESSAGES");
fs::create_dir_all(mo_dir.clone()).expect("Couldn't create MO directory");
let mo_path = mo_dir.join(format!("{}.mo", domain));
Command::new("msgfmt")
.arg(format!("--output-file={}", mo_path.to_str().unwrap()))
.arg(po_path)
.spawn()
.expect("Couldn't compile translations");
}
}
/// See the crate documentation for information
/// about how to use this macro.
#[macro_export]
macro_rules! i18n {
($cat:expr, $msg:expr) => {
$cat.gettext($msg)
};
($cat:expr, $msg:expr, $plur:expr, $count:expr) => {
$crate::try_format(cat.ngettext($msg, $plur, $count), &[ Box::new($count) ])
.expect("GetText formatting error")
};
($cat:expr, $msg:expr ; $( $args:expr ),*) => {
$crate::try_format($cat.gettext($msg), &[ $( Box::new($args) ),* ])
.expect("GetText formatting error")
};
($cat:expr, $msg:expr, $plur:expr, $count:expr ; $( $args:expr ),*) => {
$crate::try_format($cat.ngettext($msg, $plur, $count), &[ Box::new($count), $( Box::new($args) ),* ])
.expect("GetText formatting error")
};
}
/// Works the same way as `i18n`, but without needing to give a `Catalog`
/// as first argument.
///
/// For use in askama templates.
#[macro_export]
macro_rules! t {
($( $args:tt )+) => {
i18n!(self.catalog, $( $args )+)
};
}
#[derive(Debug)]
#[doc(hidden)]
pub enum FormatError {
UnmatchedCurlyBracket,
InvalidPositionalArgument,
}
#[doc(hidden)]
pub fn try_format(str_pattern: &str, argv: &[Box<std::fmt::Display>]) -> Result<String, FormatError> {
use std::fmt::Write;
//first we parse the pattern
let mut pattern = vec![];
let mut vars = vec![];
let mut finish_or_fail = false;
for (i, part) in str_pattern.split('}').enumerate() {
if finish_or_fail {
return Err(FormatError::UnmatchedCurlyBracket);
}
if part.contains('{') {
let mut part = part.split('{');
let text = part.next().unwrap();
let arg = part.next().ok_or(FormatError::UnmatchedCurlyBracket)?;
if part.next() != None {
return Err(FormatError::UnmatchedCurlyBracket);
}
pattern.push(text);
vars.push(
argv.get::<usize>(if arg.len() > 0 {
arg.parse()
.map_err(|_| FormatError::InvalidPositionalArgument)?
} else {
i
}).ok_or(FormatError::InvalidPositionalArgument)?,
);
} else {
finish_or_fail = true;
pattern.push(part);
}
}
//then we generate the result String
let mut res = String::with_capacity(str_pattern.len());
let mut pattern = pattern.iter();
let mut vars = vars.iter();
while let Some(text) = pattern.next() {
res.write_str(text).unwrap();
if let Some(var) = vars.next() {
res.write_str(&format!("{}", var)).unwrap();
}
}
Ok(res)
}
#[cfg(test)]
struct FakeCatalog;
#[cfg(test)]
impl FakeCatalog {
pub fn gettext<'a>(&self, x: &'a str) -> &'a str {
x
}
}
#[cfg(test)]
#[test]
fn test_macros() {
let catalog = FakeCatalog;
assert_eq!(
String::from("Hello, John"),
i18n!(catalog, "Hello, {0}"; "John")
);
}