You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

797 lines
24 KiB
Rust

//! A set of macros to make i18n easier.
extern crate proc_macro;
use proc_macro::TokenStream;
use proc_macro2::{
token_stream::IntoIter as TokenIter, Literal, TokenTree,
};
use quote::quote;
use std::{
env,
fs::{create_dir_all, read, File, OpenOptions},
io::{BufRead, Read, Seek, SeekFrom, Write},
path::Path,
process::{Command, Stdio},
};
use syn::Token;
fn is(t: &TokenTree, ch: char) -> bool {
match t {
TokenTree::Punct(p) => p.as_char() == ch,
_ => false,
}
}
fn named_arg(mut input: TokenIter, name: &'static str) -> Option<proc_macro2::TokenStream> {
input.next().and_then(|t| match t {
TokenTree::Ident(ref i) if i.to_string() == name => {
input.next(); // skip "="
Some(
input
.take_while(|tok| match tok {
TokenTree::Punct(_) => false,
_ => true,
})
.collect(),
)
}
_ => None,
})
}
fn root_crate_path() -> std::path::PathBuf {
let path = env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR is not set. Please use cargo to compile your crate.");
let path = Path::new(&path);
if path
.parent()
.expect("No parent dir")
.join("Cargo.toml")
.exists()
{
path.parent().expect("No parent dir").to_path_buf()
} else {
path.to_path_buf()
}
}
struct Config {
domain: String,
make_po: bool,
make_mo: bool,
langs: Vec<String>,
}
impl Config {
fn path() -> std::path::PathBuf {
Path::new(&env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| {
root_crate_path()
.join("target")
.join("debug")
.to_str()
.expect("Couldn't compute mo output dir")
.into()
}))
.join("gettext_macros")
.join(env::var("CARGO_PKG_NAME").expect("Please build with cargo"))
}
fn read() -> Config {
let config = read(Config::path())
.expect("Coudln't read domain, make sure to call init_i18n! before");
let mut lines = config.lines();
let domain = lines
.next()
.expect("Invalid config file. Make sure to call init_i18n! before this macro")
.expect("IO error while reading config");
let make_po: bool = lines
.next()
.expect("Invalid config file. Make sure to call init_i18n! before this macro")
.expect("IO error while reading config")
.parse()
.expect("Couldn't parse make_po");
let make_mo: bool = lines
.next()
.expect("Invalid config file. Make sure to call init_i18n! before this macro")
.expect("IO error while reading config")
.parse()
.expect("Couldn't parse make_mo");
Config {
domain,
make_po,
make_mo,
langs: lines
.map(|l| l.expect("IO error while reading config"))
.collect(),
}
}
fn write(&self) {
// emit file to include
create_dir_all(Config::path().parent().unwrap()).expect("Couldn't create output dir");
let mut out = File::create(Config::path()).expect("Metadata file couldn't be open");
writeln!(out, "{}", self.domain).expect("Couldn't write domain");
writeln!(out, "{}", self.make_po).expect("Couldn't write po settings");
writeln!(out, "{}", self.make_mo).expect("Couldn't write mo settings");
for l in self.langs.clone() {
writeln!(out, "{}", l).expect("Couldn't write lang");
}
}
}
trait Message {
fn writable(&self) -> bool;
fn content(&self) -> String;
fn context(&self) -> Option<String>;
fn plural(&self) -> Option<String>;
fn write(&self) {
if !self.writable() {
return;
}
let config = Config::read();
let mut pot = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(format!("po/{0}/{0}.pot", config.domain))
.expect("Couldn't open .pot file");
let mut contents = String::new();
pot.read_to_string(&mut contents)
.expect("IO error while reading .pot file");
pot.seek(SeekFrom::End(0))
.expect("IO error while seeking .pot file to end");
let already_exists = self.content().is_empty()
|| contents.contains(&format!(
r#"{}msgid "{}""#,
self.context()
.clone()
.map(|c| format!(
r#"msgctxt "{}"
"#,
c))
.unwrap_or_default(),
self.content()
));
if already_exists {
return;
}
let prefix = if let Some(c) = self.context() {
format!(
r#"msgctxt "{}"
"#, c)
} else {
String::new()
};
if let Some(ref pl) = self.plural() {
pot.write_all(
&format!(
r#"
{}msgid "{}"
msgid_plural "{}"
msgstr[0] ""
"#,
prefix, self.content(), pl,
)
.into_bytes(),
)
.expect("Couldn't write message to .pot (plural)");
} else {
pot.write_all(
&format!(
r#"
{}msgid "{}"
msgstr ""
"#,
prefix, self.content(),
)
.into_bytes(),
)
.expect("Couldn't write message to .pot");
}
}
}
struct I18nCall {
catalog: syn::Expr,
context: Option<syn::LitStr>,
msg: syn::Expr,
plural: Option<syn::Expr>,
format_args: Option<syn::punctuated::Punctuated<syn::Expr, syn::Token![,]>>,
}
mod kw {
syn::custom_keyword!(context);
}
impl syn::parse::Parse for I18nCall {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let catalog = input.parse()?;
input.parse::<Token![,]>()?;
let context = if input.parse::<kw::context>().is_ok() {
input.parse::<Token![=]>()?;
let ctx = input.parse().ok();
input.parse::<Token![,]>()?;
ctx
} else {
None
};
let msg = input.parse()?;
let plural = if input.parse::<Token![,]>().is_ok() {
input.parse().ok()
} else {
None
};
let format_args = if input.parse::<Token![;]>().is_ok() {
syn::punctuated::Punctuated::parse_terminated(input).ok()
} else {
None
};
Ok(I18nCall {
catalog,
context,
msg,
plural,
format_args,
})
}
}
fn extract_str_lit(expr: &syn::Expr) -> Option<String> {
match *expr {
syn::Expr::Lit(syn::ExprLit { lit : syn::Lit::Str(ref s), attrs: _ }) => Some(s.value()),
_ => None,
}
}
impl Message for I18nCall {
fn writable(&self) -> bool {
extract_str_lit(&self.msg).is_some()
}
fn content(&self) -> String {
extract_str_lit(&self.msg).unwrap_or_default()
}
fn context(&self) -> Option<String> {
self.context.as_ref().map(|c| c.value())
}
fn plural(&self) -> Option<String> {
self.plural.as_ref().and_then(extract_str_lit)
}
}
struct TCall {
context: Option<syn::LitStr>,
msg: syn::LitStr,
plural: Option<syn::LitStr>,
}
impl syn::parse::Parse for TCall {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let context = if input.parse::<kw::context>().is_ok() {
input.parse::<Token![=]>()?;
let ctx = input.parse().ok();
input.parse::<Token![,]>()?;
ctx
} else {
None
};
let msg = input.parse()?;
let plural = if input.parse::<Token![,]>().is_ok() {
input.parse().ok()
} else {
None
};
Ok(TCall {
context,
msg,
plural,
})
}
}
impl Message for TCall {
fn writable(&self) -> bool {
true
}
fn content(&self) -> String {
self.msg.value()
}
fn context(&self) -> Option<String> {
self.context.as_ref().map(|c| c.value())
}
fn plural(&self) -> Option<String> {
self.plural.as_ref().map(|p| p.value())
}
}
/// Marks a string as translatable
///
/// It only adds the given string to the `.pot` file, without translating it at runtime.
///
/// To translate it for real, you will have to use `i18n`. The advantage of this macro, is
/// that you mark a string as translatable without requiring a catalog to be available in scope.
///
/// # Return value
///
/// In case of a singular message, the message itself is returned.
///
/// For messages with a plural form, it is a tuple containing the singular form, and the plural one.
///
/// # Example
///
/// ```rust,ignore
/// #use gettext_macros::*;
/// // Let's say we can't have access to a Catalog at this point of the program
/// let msg = t!("Hello, world!");
/// let plural = t!("Singular", "Plural")
///
/// // Now, let's get a catalog, and translate these messages
/// let cat = get_catalog();
/// i18n!(cat, msg);
/// i18n!(cat, plural.0, plural.1; 57);
/// ```
///
/// # Syntax
///
/// This macro accepts the following syntaxes:
///
/// ```rust,ignore
/// t!($singular)
/// t!($singular, $plural)
/// t!(context = $ctx, $singular)
/// t!(context = $ctx, $singular, $plural)
/// ```
///
/// Where `$singular`, `$plural` and `$ctx` all are `str` literals (and not variables, expressions or literal of any other type).
#[proc_macro]
pub fn t(input: TokenStream) -> TokenStream {
let message = syn::parse_macro_input!(input as TCall);
message.write();
let msg = message.content();
if let Some(pl) = message.plural.clone() {
quote!(
(#msg, #pl)
).into()
} else {
quote!(#msg).into()
}
}
/// Marks a string as translatable and translate it at runtime.
///
/// It add the string to the `.pot` file and translate them at runtime, using a given `gettext::Catalog`.
///
/// # Return value
///
/// This macro returns the translated string.
///
/// # Panics
///
/// This macro will panic if it the format string (of the translation) does not match the
/// format arguments that were given. For instance, if you have a string `Hello!`, that
/// is translated in Esperanto as `Saluton {name}!`, and that you call this function without
/// any format argument (as expected in the original English string), it will panic.
///
/// # Examples
///
/// Basic usage:
///
/// ```rust,ignore
/// // cat is the gettext::Catalog containing translations for the current locale.
/// let cat = get_catalog();
/// i18n!(cat, "Hello, world!");
/// ```
///
/// Formatting a translated string:
///
/// ```rust,ignore
/// let name = "Peter";
/// i18n!(cat, "Hi {0}!"; name);
///
/// // Also works with multiple format arguments
/// i18n!(cat, "You are our {}th visitor! You won ${}!"; 99_999, 2);
/// ```
///
/// With a context, that will be shown to translators:
///
/// ```rust,ignore
/// let name = "Sophia";
/// i18n!(cat, context = "The variable is the name of the person being greeted", "Hello, {0}!"; name);
/// ```
///
/// Translating string that changes depending on a number:
///
/// ```rust,ignore
/// let flowers_count = 18;
/// i18n!(cat, "What a nice flower!", "What a nice garden!"; flowers_count);
/// ```
///
/// With all available options:
///
/// ```rust,ignore
/// let updates = 69;
/// i18n!(
/// cat,
/// context = "The notification when updates are available.",
/// "There is {} app update available."
/// "There are {} app updates available.";
/// updates
/// );
/// ```
///
/// # Syntax
///
/// This macro expects:
///
/// - first, the expression to get the translation catalog to use
/// - then, optionally, the `context` named argument, that is a string that will be shown
/// to translators. It should be a `str` literal, because it needs to be known at compile time.
/// - the message to translate. It can either be a string literal, or an expression, but if you use the later
/// make sure that the string is correctly added to the `.pot` file with `t`.
/// - if this message has a plural version, it should come after. Here too, both string literals or other expressions
/// are allowed
///
/// All these arguments should be separated by commas.
///
/// If you want to pass format arguments to this macro, to have them inserted into the translated strings,
/// you should add them at the end, after a colon, and seperate them with commas too.
#[proc_macro]
pub fn i18n(input: TokenStream) -> TokenStream {
let message = syn::parse_macro_input!(input as I18nCall);
message.write();
let gettext_call = message.catalog.clone();
let content = message.msg;
let gettext_call = if let Some(pl) = message.plural {
let count = message
.format_args
.clone()
.and_then(|args| args.first().cloned());
if let Some(c) = message.context {
quote!(
#gettext_call.npgettext(#c, #content, #pl, #count as u64)
)
} else {
quote!(
#gettext_call.ngettext(#content, #pl, #count as u64)
)
}
} else {
if let Some(c) = message.context {
quote!(
#gettext_call.pgettext(#c, #content)
)
} else {
quote!(
#gettext_call.gettext(#content)
)
}
};
let fargs: syn::punctuated::Punctuated<proc_macro2::TokenStream, Token![,]> = message.format_args.unwrap_or_default().into_iter().map(|x| {
quote!(::std::boxed::Box::new(#x))
}).collect();
let res = quote!({
use gettext_utils::try_format;
try_format(#gettext_call, &[#fargs]).expect("Error while formatting message")
});
res.into()
}
/// This macro configures internationalization for the current crate
///
/// This macro expands to nothing, it just write your configuration to files
/// for other macros calls, and creates the `.pot` file if needed.
///
/// This macro should be called before (not in the program flow, but in the Rust parser flow) all other
/// internationalization macros.
///
/// # Examples
///
/// Basic usage:
///
/// ```rust,ignore
/// init_i18n!("my_app", de, en, eo, fr, ja, pl, ru);
/// ```
/// With `.po` and `.mo` generation turned off, and without comments about string location in the `.pot`:
///
/// ```rust,ignore
/// init_i18n!("my_app", po = false, mo = false, de, en, eo, fr, ja, pl, ru);
/// ```
///
/// # Syntax
///
/// This macro expects:
///
/// - a string literal, that is the translation domain of your crate.
/// - optionally, the `po` named argument, that is a boolean literal to turn off `.po` generation from `.pot` in `compile_i18n`
/// - optionally, the `mo` named argument, that is a boolean literal too, to turn of `.po` compilation into `.mo` files in `compile_i18n`.
/// Note that if you turn this feature off, `include_i18n` won't work unless you manually generate the `.mo` files in
/// `target/TARGET/gettext_macros/LOCALE/DOMAIN.mo`.
/// - optionally, the `location` named argument, a boolean too, to avoid writing the location of the string in the source code to translation files.
/// Having this location available can be usefull if your translators know a bit of Rust and needs context about what they are translating, but it
/// also makes bigger diffs, because your `.pot` and `.po` files may be regenerated if a line number changes.
/// - then, the list of languages you want your app to be translated in, separated by commas. The languages are not string literals, but identifiers.
///
/// All the three boolean options are turned on by default. Also note that you may ommit one (or more) of them, but they should always be in this order.
#[proc_macro]
pub fn init_i18n(input: TokenStream) -> TokenStream {
let input = proc_macro2::TokenStream::from(input);
let mut input = input.into_iter();
let domain = match input.next() {
Some(TokenTree::Literal(lit)) => lit.to_string().replace("\"", ""),
Some(_) => panic!("Domain should be a str"),
None => panic!("Expected a translation domain (for instance \"myapp\")"),
};
let (po, mo) = if let Some(n) = input.next() {
if is(&n, ',') {
let po = named_arg(input.clone(), "po");
if let Some(po) = po.clone() {
for _ in 0..(po.into_iter().count() + 3) {
input.next();
}
}
let mo = named_arg(input.clone(), "mo");
if let Some(mo) = mo.clone() {
for _ in 0..(mo.into_iter().count() + 3) {
input.next();
}
}
(po, mo)
} else {
(None, None)
}
} else {
(None, None)
};
let mut langs = vec![];
match input.next() {
Some(TokenTree::Ident(i)) => {
langs.push(i.to_string());
loop {
let next = input.next();
if next.is_none() || !is(&next.expect("Unreachable: next should be Some"), ',') {
break;
}
match input.next() {
Some(TokenTree::Ident(i)) => {
langs.push(i.to_string());
}
_ => panic!("Expected a language identifier"),
}
}
}
None => {}
_ => panic!("Expected a language identifier"),
}
let conf = Config {
domain: domain.clone(),
make_po: po.map(|x| x.to_string() == "true").unwrap_or(true),
make_mo: mo.map(|x| x.to_string() == "true").unwrap_or(true),
langs,
};
conf.write();
// write base .pot
create_dir_all(format!("po/{}", domain)).expect("Couldn't create po dir");
let mut pot = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(format!("po/{0}/{0}.pot", domain))
.expect("Couldn't open .pot file");
pot.write_all(
&format!(
r#"msgid ""
msgstr ""
"Project-Id-Version: {}\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-15 16:33-0700\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
"#,
domain
)
.into_bytes(),
)
.expect("Couldn't init .pot file");
quote!().into()
}
/// Gives you the translation domain for the current crate.
///
/// # Return value
///
/// A `'static str` containing the GetText domain of this crate.
///
/// # Example
///
/// ```rust,ignore
/// println!("The GetText domain is: {}", i18n_domain!());
/// ```
#[proc_macro]
pub fn i18n_domain(_: TokenStream) -> TokenStream {
let domain = Config::read().domain;
let tok = TokenTree::Literal(Literal::string(&domain));
quote!(#tok).into()
}
/// Compiles your internationalization files.
///
/// This macro expands to nothing, it just writes `.po` and `.mo` files.
///
/// You can configure its behavior with the `po` and `mo` options of `init_i18n`.
///
/// This macro should be called after (not in the program flow, but in the Rust parser flow) all other internationlaziton macros,
/// expected `include_i18n`.
///
/// # Example
///
/// ```rust,ignore
/// compile_i18n!();
/// ```
#[proc_macro]
pub fn compile_i18n(_: TokenStream) -> TokenStream {
let conf = Config::read();
let domain = &conf.domain;
let pot_path = root_crate_path()
.join("po")
.join(domain.clone())
.join(format!("{}.pot", domain));
for lang in conf.langs {
let po_path = root_crate_path()
.join("po")
.join(domain.clone())
.join(format!("{}.po", lang.clone()));
if conf.make_po {
if po_path.exists() && po_path.is_file() {
// Update it
Command::new("msgmerge")
.arg("-U")
.arg(po_path.to_str().expect("msgmerge: PO path error"))
.arg(pot_path.to_str().expect("msgmerge: POT path error"))
.stdout(Stdio::null())
.status()
.map(|s| {
if !s.success() {
panic!("Couldn't update PO file")
}
})
.expect("Couldn't update PO file. Make sure msgmerge is installed.");
} else {
println!("Creating {}", lang.clone());
// Create it from the template
Command::new("msginit")
.arg(format!(
"--input={}",
pot_path.to_str().expect("msginit: POT path error")
))
.arg(format!(
"--output-file={}",
po_path.to_str().expect("msginit: PO path error")
))
.arg("-l")
.arg(lang.clone())
.arg("--no-translator")
.stdout(Stdio::null())
.status()
.map(|s| {
if !s.success() {
panic!("Couldn't init PO file (gettext returned an error)")
}
})
.expect("Couldn't init PO file. Make sure msginit is installed.");
}
}
if conf.make_mo {
if !po_path.exists() {
panic!(
"{} doesn't exist. Make sure you didn't disabled po generation.",
po_path.display()
);
}
// Generate .mo
let mo_dir = Path::new(&env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| {
root_crate_path()
.join("target")
.join("debug")
.to_str()
.expect("Couldn't compute mo output dir")
.into()
}))
.join("gettext_macros")
.join(lang);
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().expect("msgfmt: MO path error")
))
.arg(po_path)
.stdout(Stdio::null())
.status()
.map(|s| {
if !s.success() {
panic!("Couldn't compile translations (gettext returned an error)")
}
})
.expect("Couldn't compile translations. Make sure msgfmt is installed");
}
}
quote!().into()
}
/// Use this macro to staticaly import translations into your final binary.
///
/// This macro won't work if ou set `mo = false` in `init_i18n`, unless you manually generate the `.mo` files in
/// `target/TARGET/gettext_macros/LOCALE/DOMAIN.mo`.
///
/// # Example
///
/// ```rust,ignore
/// let catalogs = include_i18n!();
/// catalog.into_iter()
/// .find(|(lang, _)| lang == "eo")
/// .map(|(_, catalog| println!("{}", i18n!(catalog, "Hello world!")));
/// ```
#[proc_macro]
pub fn include_i18n(_: TokenStream) -> TokenStream {
let conf = Config::read();
let locales = conf.langs.clone().into_iter().map(|l| {
let lang = TokenTree::Literal(Literal::string(&l));
let path = Config::path().parent().unwrap().join(l).join(format!("{}.mo", conf.domain));
if !path.exists() {
panic!("{} doesn't exist. Make sure to call compile_i18n! before include_i18n!, and check that you didn't disabled mo compilation.", path.display());
}
let path = TokenTree::Literal(Literal::string(path.to_str().expect("Couldn't write MO file path")));
quote!{
(#lang, ::gettext::Catalog::parse(
&include_bytes!(
#path
)[..]
).expect("Error while loading catalog")),
}
}).collect::<proc_macro2::TokenStream>();
quote!({
vec![
#locales
]
}).into()
}