├── .gitignore ├── xtr ├── lang │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── xtr.mo │ │ │ └── xtr.po │ └── fr │ │ └── LC_MESSAGES │ │ ├── xtr.mo │ │ └── xtr.po ├── update_translations.sh ├── Cargo.toml └── src │ ├── generator.rs │ ├── crate_visitor.rs │ ├── main.rs │ └── extract_messages.rs ├── Cargo.toml ├── .travis.yml ├── .github └── workflows │ └── rust.yml ├── tr ├── Cargo.toml ├── tests │ └── uppercase.rs ├── benches │ └── my_bench.rs └── src │ ├── lib.rs │ └── rspolib_translator.rs ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | *.pot -------------------------------------------------------------------------------- /xtr/lang/de/LC_MESSAGES/xtr.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woboq/tr/HEAD/xtr/lang/de/LC_MESSAGES/xtr.mo -------------------------------------------------------------------------------- /xtr/lang/fr/LC_MESSAGES/xtr.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woboq/tr/HEAD/xtr/lang/fr/LC_MESSAGES/xtr.mo -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | resolver = "2" 4 | 5 | members = [ 6 | "tr", "xtr" 7 | ] 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: 3 | - stable 4 | - beta 5 | - nightly 6 | script: 7 | - cargo test 8 | - cargo run xtr --help 9 | - LANGUAGE=fr cargo run xtr --help 10 | - LANGUAGE=de cargo run xtr --help 11 | notifications: 12 | email: 13 | on_success: never 14 | -------------------------------------------------------------------------------- /xtr/update_translations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run the script, translate, run the script again 4 | 5 | cargo run src/main.rs --package-name=xtr -d xtr -o xtr.pot 6 | 7 | for po in lang/*/LC_MESSAGES 8 | do msgmerge $po/xtr.po xtr.pot -o $po/xtr.po 9 | msgfmt $po/xtr.po -o $po/xtr.mo 10 | done 11 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | rust: 19 | # MSRV 20 | - 1.81 21 | - stable 22 | - beta 23 | - nightly 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: dtolnay/rust-toolchain@stable 28 | with: 29 | toolchain: ${{ matrix.rust }} 30 | - name: Build 31 | run: cargo build --verbose --all-features 32 | - name: Run tests 33 | run: cargo test --verbose --all-features 34 | - name: Run tests (release) 35 | run: cargo test --release --verbose --all-features 36 | -------------------------------------------------------------------------------- /tr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tr" 3 | version = "0.1.11" 4 | authors = ["Olivier Goffart "] 5 | description = "tr! macro for localisation" 6 | license = "MIT" 7 | readme = "../README.md" 8 | repository = "https://github.com/woboq/tr" 9 | documentation = "https://docs.rs/tr" 10 | keywords = ["internationalization", "translation", "l10n", "i18n", "gettext"] 11 | categories = ["internationalization", "localization"] 12 | edition = "2021" 13 | rust-version = "1.81" 14 | 15 | [features] 16 | default = ["gettext-rs"] 17 | mo-translator = ["dep:rspolib"] 18 | po-translator = ["dep:rspolib"] 19 | 20 | [dependencies] 21 | gettext-rs = { version = "0.7", optional = true, features = ["gettext-system"] } 22 | gettext = { version = "0.4", optional = true } 23 | rspolib = { version = "0.1.1", optional = true } 24 | 25 | [dev-dependencies] 26 | criterion = "0.6" 27 | 28 | [[bench]] 29 | name = "my_bench" 30 | harness = false 31 | 32 | [package.metadata.docs.rs] 33 | features = ["mo-translator", "po-translator", "gettext-rs"] 34 | -------------------------------------------------------------------------------- /xtr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xtr" 3 | version = "0.1.11" 4 | authors = ["Olivier Goffart "] 5 | description = "Extract strings from a rust crate to be translated with gettext" 6 | license = "AGPL-3.0" 7 | readme = "../README.md" 8 | repository = "https://github.com/woboq/tr" 9 | documentation = "https://docs.rs/tr" 10 | keywords = ["localization", "l10n", "i18n", "gettext", "xgettext"] 11 | categories = ["internationalization", "localization"] 12 | edition = "2018" 13 | 14 | 15 | [dependencies] 16 | proc-macro2 = { version = "1", features = ["span-locations"] } 17 | # syn is used in crate_visitor.rs to parse a file and visit the `mod` location. 18 | # It is also used in extract_message.rs to parse the literals into their string representation 19 | syn = { version = "2", features=["full", "visit", "extra-traits", "printing"] } 20 | quote = "1" 21 | clap = { version = "4.2", features = ["cargo"] } 22 | tr = { path="../tr", version = "0.1" } 23 | anyhow = "1" 24 | chrono = { version = "0.4.6", default-features = false, features = ["clock"] } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.11 - 2025-06-23 4 | 5 | - Set MSRV to Rust 1.81 6 | - Fixed clippy warnings 7 | 8 | ### tr 9 | 10 | - Remove `lazy_static` dependency 11 | - Added unset_translator!() macro, to permit undoing a previous set_translator!() call 12 | - Implement `Translator` for `std::syn::Arc` 13 | - Added `MoTranslator` and `PoTranslator` translators 14 | 15 | ## 0.1.10 - 2024-08-11 16 | 17 | ### tr 18 | 19 | - Use the name of the crate in `set_translator!` instead of the full module (#24) 20 | - Allow `format_args!` as argument to `tr!` (#25) 21 | 22 | ### xtr 23 | 24 | - Support module with raw identifier (#19) 25 | - Allow `mod bar;` in `foo.rs` refer to `foo/bar/mod.rs` (#23) 26 | 27 | ## 0.1.9 - 2023-06-12 28 | 29 | ### xtr 30 | 31 | - Turn --omit-header back into flag (#17) 32 | 33 | ## 0.1.8 - 2023-05-16 34 | 35 | ### xtr 36 | 37 | - Restore `--default-domain` flag (#15) 38 | 39 | ## 0.1.7 - 2023-04-21 40 | 41 | ### tr 42 | 43 | - Updated dependencies 44 | 45 | ### xtr 46 | 47 | - Fix Panic with test modules in external files (#12) 48 | - Updated dependencies 49 | 50 | ## 0.1.6 - 2021-02-14 51 | 52 | ### tr 53 | 54 | - Updated dependencies 55 | 56 | ### xtr 57 | 58 | - Fix compilation with syn 1.0.58 59 | -------------------------------------------------------------------------------- /tr/tests/uppercase.rs: -------------------------------------------------------------------------------- 1 | use tr::{set_translator, tr, unset_translator, Translator}; 2 | 3 | struct UpperCaseTranslator; 4 | impl crate::Translator for UpperCaseTranslator { 5 | fn translate<'a>( 6 | &'a self, 7 | string: &'a str, 8 | _context: Option<&'a str>, 9 | ) -> std::borrow::Cow<'a, str> { 10 | string.to_uppercase().into() 11 | } 12 | 13 | fn ntranslate<'a>( 14 | &'a self, 15 | n: u64, 16 | singular: &'a str, 17 | plural: &'a str, 18 | _context: Option<&'a str>, 19 | ) -> std::borrow::Cow<'a, str> { 20 | if n == 1 { 21 | singular.to_uppercase().into() 22 | } else { 23 | plural.to_uppercase().into() 24 | } 25 | } 26 | } 27 | 28 | #[test] 29 | fn uppercase() { 30 | let arc = std::sync::Arc::new(UpperCaseTranslator); 31 | set_translator!(arc); 32 | 33 | assert_eq!(tr!("Hello"), "HELLO"); 34 | assert_eq!(tr!("ctx" => "Hello"), "HELLO"); 35 | assert_eq!(tr!("Hello {}", "world"), "HELLO world"); 36 | assert_eq!(tr!("ctx" => "Hello {}", tr!("world")), "HELLO WORLD"); 37 | 38 | assert_eq!( 39 | tr!("I have one item" | "I have {n} items" % 1), 40 | "I HAVE ONE ITEM" 41 | ); 42 | assert_eq!( 43 | tr!("ctx" => "I have one item" | "I have {n} items" % 42), 44 | "I HAVE {N} ITEMS" // uppercased n is not replaced 45 | ); 46 | 47 | unset_translator!(); 48 | assert_eq!(tr!("Hello"), "Hello"); 49 | } 50 | -------------------------------------------------------------------------------- /tr/benches/my_bench.rs: -------------------------------------------------------------------------------- 1 | use criterion::{criterion_group, criterion_main, Criterion}; 2 | use std::hint::black_box; 3 | use tr::tr; 4 | 5 | pub fn short_literal(c: &mut Criterion) { 6 | c.bench_function("short_literal", |b| { 7 | b.iter(|| { 8 | tr!("Hello"); 9 | }) 10 | }); 11 | } 12 | 13 | pub fn long_literal(c: &mut Criterion) { 14 | c.bench_function("long_literal", |b| b.iter(|| { 15 | tr!("Hello, world! This is a longer sentence but without argument markers. That is all for now, thank you for reading."); 16 | })); 17 | } 18 | 19 | pub fn short_argument(c: &mut Criterion) { 20 | c.bench_function("short_argument", |b| { 21 | b.iter(|| { 22 | tr!("Hello {}!", black_box("world")); 23 | }) 24 | }); 25 | } 26 | 27 | pub fn long_argument(c: &mut Criterion) { 28 | c.bench_function("long_argument", |b| { 29 | b.iter(|| { 30 | tr!( 31 | "Hello {} and {} and {} and {} and {} and {} and {} and finally {}!", 32 | black_box("Mercury"), 33 | black_box("Venus"), 34 | black_box("Earth"), 35 | black_box("Mars"), 36 | black_box("Jupiter"), 37 | black_box("Saturn"), 38 | black_box("Uranus"), 39 | black_box("Neptune"), 40 | ); 41 | }) 42 | }); 43 | } 44 | 45 | criterion_group!( 46 | benches, 47 | short_literal, 48 | long_literal, 49 | short_argument, 50 | long_argument 51 | ); 52 | criterion_main!(benches); 53 | -------------------------------------------------------------------------------- /xtr/lang/de/LC_MESSAGES/xtr.po: -------------------------------------------------------------------------------- 1 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 2 | # This file is distributed under the same license as the PACKAGE package. 3 | # 4 | # Olivier Goffart , 2018, 2019, 2023. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: \n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2023-05-16 10:54+0000\n" 10 | "PO-Revision-Date: 2023-04-21 18:08+0200\n" 11 | "MIME-Version: 1.0\n" 12 | "Content-Type: text/plain; charset=UTF-8\n" 13 | "Content-Transfer-Encoding: 8bit\n" 14 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 15 | "X-Generator: Lokalize 2.0\n" 16 | 17 | #: src/main.rs:110 18 | msgid "Extract strings from a rust crate to be translated with gettext" 19 | msgstr "" 20 | "Extrahieren Zeichenfolge aus einer Rust Crate, um sie mit gettext zu " 21 | "übersetzen" 22 | 23 | #: src/main.rs:117 24 | msgid "Use name.po for output (instead of messages.po)" 25 | msgstr "Ausgabe in NAME.po (anstatt in messages.po)" 26 | 27 | #: src/main.rs:120 28 | msgid "Write output to specified file (instead of messages.po)." 29 | msgstr "Ausgabe in die angegebene datei schreiben (anstatt in messages.po)" 30 | 31 | #. documentation for keywordspec goes here 32 | #: src/main.rs:127 33 | msgid "" 34 | "Specify keywordspec as an additional keyword to be looked for. Refer to the " 35 | "xgettext documentation for more info." 36 | msgstr "" 37 | "Ausgabe ein zusätzliches Schlüsselwort an nach dem gesucht werden soll." 38 | "Weitere Informationen finden Sie in der xgettext-Dokumentation." 39 | 40 | #: src/main.rs:134 41 | msgid "Don’t write header with ‘msgid \"\"’ entry" 42 | msgstr "»msgid \"\"«-Eintrag im Kopfteil nicht erstellen" 43 | 44 | #: src/main.rs:140 45 | msgid "Set the copyright holder in the output." 46 | msgstr "Uhrheberrechtsinhaber in Ausgabe setzen" 47 | 48 | #: src/main.rs:146 49 | msgid "Set the package name in the header of the output." 50 | msgstr "Paketname für die Ausgabe setzen" 51 | 52 | #: src/main.rs:152 53 | msgid "Set the package version in the header of the output." 54 | msgstr "Paketversion in Ausgabe setzen" 55 | 56 | #: src/main.rs:159 57 | msgid "" 58 | "Set the reporting address for msgid bugs. This is the email address or URL " 59 | "to which the translators shall report bugs in the untranslated strings" 60 | msgstr "" 61 | "Adresse für msgid-Fehler angeben. Dies ist die E-Mail-Adresse oder URL, an " 62 | "die die Übersetzer Fehler in den nicht übersetzten Zeichenfolgen melden " 63 | "sollen" 64 | 65 | #: src/main.rs:169 66 | msgid "The encoding used for the characters in the POT file's locale." 67 | msgstr "" 68 | 69 | #: src/main.rs:177 70 | msgid "" 71 | "How much message location information to include in the output. (default). " 72 | "If the type is ‘full’ (the default), it generates the lines with both file " 73 | "name and line number: ‘#: filename:line’. If it is ‘file’, the line number " 74 | "part is omitted: ‘#: filename’. If it is ‘never’, nothing is generated." 75 | msgstr "" 76 | 77 | #. documentation for the input 78 | #: src/main.rs:190 79 | msgid "Main rust files to parse (will recurse into modules)" 80 | msgstr "Haupt-Rust-Dateien zum Parsen (werden in Module zerlegt)" 81 | 82 | #~ msgid "Rust file to parse" 83 | #~ msgstr "Rust-Datei zum Analysieren" 84 | -------------------------------------------------------------------------------- /xtr/lang/fr/LC_MESSAGES/xtr.po: -------------------------------------------------------------------------------- 1 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 2 | # This file is distributed under the same license as the PACKAGE package. 3 | # 4 | # Olivier Goffart , 2018, 2019, 2023. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: \n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2023-05-16 10:54+0000\n" 10 | "PO-Revision-Date: 2023-04-21 18:07+0200\n" 11 | "MIME-Version: 1.0\n" 12 | "Content-Type: text/plain; charset=UTF-8\n" 13 | "Content-Transfer-Encoding: 8bit\n" 14 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 15 | "X-Generator: Lokalize 2.0\n" 16 | 17 | #: src/main.rs:110 18 | msgid "Extract strings from a rust crate to be translated with gettext" 19 | msgstr "" 20 | "Extrait les chaine de charactère depuis a crate rust, pour être traduit avec " 21 | "gettext" 22 | 23 | #: src/main.rs:117 24 | msgid "Use name.po for output (instead of messages.po)" 25 | msgstr "Utilise name.po pour le fichier de sortie (à la place de messages.po)" 26 | 27 | #: src/main.rs:120 28 | msgid "Write output to specified file (instead of messages.po)." 29 | msgstr "" 30 | "Écrit la sortie dans le fichier spécifier (à la place de messages.po). " 31 | 32 | #. documentation for keywordspec goes here 33 | #: src/main.rs:127 34 | msgid "" 35 | "Specify keywordspec as an additional keyword to be looked for. Refer to the " 36 | "xgettext documentation for more info." 37 | msgstr "" 38 | "Specifie keywordspec comme mot clé additional. La documentation de xgettext " 39 | "contiens plus d'information" 40 | 41 | #: src/main.rs:134 42 | msgid "Don’t write header with ‘msgid \"\"’ entry" 43 | msgstr "N'écris pas l'en-tête avec « msgid \"\" »" 44 | 45 | #: src/main.rs:140 46 | msgid "Set the copyright holder in the output." 47 | msgstr "Spécifie le détenneur du copyright dans la sortie" 48 | 49 | #: src/main.rs:146 50 | msgid "Set the package name in the header of the output." 51 | msgstr "Spécifie le nom du packet" 52 | 53 | #: src/main.rs:152 54 | msgid "Set the package version in the header of the output." 55 | msgstr "donne la version du packet" 56 | 57 | #: src/main.rs:159 58 | msgid "" 59 | "Set the reporting address for msgid bugs. This is the email address or URL " 60 | "to which the translators shall report bugs in the untranslated strings" 61 | msgstr "" 62 | "donne l'addresse pour reporter les bugs dans les texte. C'est une addresse " 63 | "email ou une URL que les traducteurs peuvent utiliser pour rapporter des " 64 | "bugs dans les chaine non traduite" 65 | 66 | #: src/main.rs:169 67 | msgid "The encoding used for the characters in the POT file's locale." 68 | msgstr "L'encodage utilisé pour les caractères dans le fichier POT." 69 | 70 | #: src/main.rs:177 71 | msgid "" 72 | "How much message location information to include in the output. (default). " 73 | "If the type is ‘full’ (the default), it generates the lines with both file " 74 | "name and line number: ‘#: filename:line’. If it is ‘file’, the line number " 75 | "part is omitted: ‘#: filename’. If it is ‘never’, nothing is generated." 76 | msgstr "" 77 | "Quelle quantité d'informations sur l'emplacement du message à inclure dans " 78 | "la sortie. Avec \"full\" (par défaut), génère les lignes avec le nom du " 79 | "ficher et le numéro de ligne : ‘#: fichier:ligne’. Avec \"file\", le numéro " 80 | "de ligne est omis : ‘#: fichier. Si c'est \"never\", rien n'est généré." 81 | 82 | #. documentation for the input 83 | #: src/main.rs:190 84 | msgid "Main rust files to parse (will recurse into modules)" 85 | msgstr "" 86 | "Fichers rust principaux à parser (va analyser les module récursivement)" 87 | 88 | #~ msgid "Rust file to parse" 89 | #~ msgstr "Fichier Rust à parser" 90 | -------------------------------------------------------------------------------- /xtr/src/generator.rs: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2018 Olivier Goffart 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU Affero General Public License as 5 | published by the Free Software Foundation, either version 3 of the 6 | License, or (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License 14 | along with this program. If not, see . 15 | */ 16 | 17 | use super::{AddLocation, Message, OutputDetails}; 18 | use chrono::prelude::*; 19 | use std::io::prelude::*; 20 | use std::path::Path; 21 | 22 | pub fn generate<'a, P: AsRef>( 23 | output: P, 24 | output_details: OutputDetails, 25 | messages: impl IntoIterator, 26 | ) -> ::std::io::Result<()> { 27 | let mut output = std::fs::File::create(output)?; 28 | 29 | if !output_details.omit_header { 30 | let package = output_details 31 | .package_name 32 | .as_ref() 33 | .map(|x| x.as_ref()) 34 | .unwrap_or("PACKAGE"); 35 | write!( 36 | output, 37 | r#"# SOME DESCRIPTIVE TITLE. 38 | # Copyright (C) YEAR {copyright} 39 | # This file is distributed under the same license as the {package} package. 40 | # FIRST AUTHOR , YEAR. 41 | # 42 | #, fuzzy 43 | msgid "" 44 | msgstr "" 45 | "Project-Id-Version: {package} {version}\n" 46 | "Report-Msgid-Bugs-To: {address}\n" 47 | "POT-Creation-Date: {date}\n" 48 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 49 | "Last-Translator: FULL NAME \n" 50 | "Language-Team: LANGUAGE \n" 51 | "Language: \n" 52 | "MIME-Version: 1.0\n" 53 | "Content-Type: text/plain; charset={charset}\n" 54 | "Content-Transfer-Encoding: 8bit\n" 55 | "#, 56 | package = package, 57 | version = output_details 58 | .package_version 59 | .as_ref() 60 | .map(|x| x.as_ref()) 61 | .unwrap_or("VERSION"), 62 | copyright = output_details 63 | .copyright_holder 64 | .unwrap_or_else(|| format!("THE {}'S COPYRIGHT HOLDER", package)), 65 | date = Utc::now().format("%Y-%m-%d %H:%M%z"), 66 | address = output_details.bugs_address.unwrap_or_default(), 67 | charset = output_details.charset, 68 | )?; 69 | } 70 | 71 | for m in messages { 72 | writeln!(output)?; 73 | if let Some(ref c) = m.comments { 74 | for c in c.split('\n') { 75 | writeln!(output, "#. {}", c)?; 76 | } 77 | } 78 | if !m.locations.is_empty() && (output_details.add_location != AddLocation::Never) { 79 | write!(output, "#:")?; 80 | for l in &m.locations { 81 | match output_details.add_location { 82 | AddLocation::Full => { 83 | write!(output, " {}:{}", l.file.to_string_lossy(), l.line)?; 84 | } 85 | AddLocation::File => { 86 | write!(output, " {}", l.file.to_string_lossy())?; 87 | } 88 | _ => panic!( 89 | "unsupported add-location option {0:?}", 90 | output_details.add_location 91 | ), 92 | } 93 | } 94 | writeln!(output)?; 95 | } 96 | if let Some(ref c) = m.msgctxt { 97 | writeln!(output, "msgctxt {}", escape(c))?; 98 | } 99 | 100 | writeln!(output, "msgid {}", escape(&m.msgid))?; 101 | 102 | if let Some(ref c) = m.plural { 103 | writeln!(output, "msgid_plural {}", escape(c))?; 104 | writeln!(output, "msgstr[0] \"\"")?; 105 | writeln!(output, "msgstr[1] \"\"")?; 106 | } else { 107 | writeln!(output, "msgstr \"\"")?; 108 | } 109 | } 110 | Ok(()) 111 | } 112 | 113 | fn escape(s: &str) -> String { 114 | format!( 115 | "\"{}\"", 116 | s.replace('\\', "\\\\") 117 | .replace('\"', "\\\"") 118 | .replace('\n', "\\n\"\n\"") 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Localisation of rust applications 2 | 3 | [![docs.rs](https://docs.rs/tr/badge.svg)](https://docs.rs/tr) 4 | 5 | This repository provides tools for localizing Rust applications, making it easier to translate your software to different languages. 6 | 7 | There are two crates 8 | 9 | * `tr` is a runtime library wrapping gettext (currently), in order to provide a 10 | convenient way to localize an application. 11 | 12 | * `xtr` is a binary similar to GNU's `xgettext` which extract string from a rust crate. 13 | It can extract strings of crate using the `tr` macro from this sibling crate, or using other 14 | gettext based localisation crates such as [`gettext-rs`](https://crates.io/crates/gettext-rs), 15 | [`gettext`](https://crates.io/crates/gettext), [`rocket_i18n`](https://github.com/BaptisteGelez/rocket_i18n) 16 | 17 | # How to translate a rust application 18 | 19 | 1. Annotate the strings in your source code with the write macro/functions. You can use 20 | * The `tr!` macro from this `tr` crate (still work in progress), or 21 | * The gettext function from the `gettext` or the `gettext-rs` crate 22 | 23 | 2. Run the `xtr` program over your crate to extract the string in a .pot file 24 | 25 | 3. Use the GNU gettext tools to merge, translate, and generate the .mo files 26 | 27 | # About `tr!` 28 | 29 | * The name comes from Qt's `tr()` function. It is a short name since it will be placed on most 30 | string literal. 31 | * The macro can do rust-style formatting. This makes it possible to re-order the arguments in the translations. 32 | * `Hello {}` or `Hello {0}` or Hello `Hello {name}` works. 33 | * Currently, the default backend uses the [`gettext-rs`](https://crates.io/crates/gettext-rs) crate, 34 | but this could be changed to [`gettext`](https://crates.io/crates/gettext) in the future. 35 | * Plurals are handled by gettext, which support the different plurals forms of several languages. 36 | 37 | ## Future plans 38 | 39 | * Validity of the formatting in the original or translation is not done yet, but could be done in the 40 | future 41 | * More advanced formatting that would allow for gender or case can be done as an extension to the 42 | formatting rules. Since the macro takes the arguments directly, it will be possible to extend the 43 | formatting engine with a [scripting system](https://techbase.kde.org/Localization/Concepts/Transcript) 44 | or something like ICU MessageFormat. 45 | * Formatting date/number in a localized fashion. 46 | 47 | ## Example 48 | 49 | ```Rust 50 | #[macro_use] 51 | extern crate tr; 52 | fn main() { 53 | // use the tr_init macro to tell gettext where to look for translations 54 | tr_init!("/usr/share/locale/"); 55 | let folder = if let Some(folder) = std::env::args().nth(1) { 56 | folder 57 | } else { 58 | println!("{}", tr!("Please give folder name")); 59 | return; 60 | }; 61 | match std::fs::read_dir(&folder) { 62 | Err(e) => { 63 | println!("{}", tr!("Could not read directory '{}'\nError: {}", 64 | folder, e)); 65 | } 66 | Ok(r) => { 67 | // Singlular/plural formating 68 | println!("{}", tr!( 69 | "The directory {} has one file" | "The directory {} has {n} files" % r.count(), 70 | folder 71 | )); 72 | } 73 | } 74 | } 75 | ``` 76 | 77 | # About `xtr` 78 | 79 | `xtr` is a tool that extract translated strings from the source code of a rust crate. 80 | The tool is supposed to be compatible with any gettext based functions. But support for the 81 | special syntax of the tr! macro has been added. 82 | 83 | ## Usage 84 | 85 | ``` 86 | xtr src/main.rs -o example.pot 87 | ``` 88 | 89 | This will extract strings from all the crate's modules and create a file `example.pot`. 90 | You can now use the gettext tools to translate this file. 91 | 92 | ## Differences with `xgettext` 93 | 94 | `xtr` is basically to be used in place of `xgettext` for Rust code. 95 | `xgettext` does not currently support the rust language. We can get decent result 96 | using the C language, but: 97 | 98 | * `xgettext` will not work properly if the code contains comments or string escaping that is 99 | not compatible with Rust's rules. (Rules for comments, or string escaping are different in 100 | Rust and in C. Think about raw literal, embedded comments, lifetime, ...) 101 | `xtr` uses the lexer from the `proc_macro2` crate so it parse rust code. 102 | * `xgettext` cannot be told to extract string out of a macro, while `xtr` will ignore the `!` 103 | token. So `gettext(...)` or `gettext!(...)` will work. 104 | * `xgettext` cannot handle the rust rules within the string literal. `xtr` will have no problem 105 | with rust's raw literal or rust's escape sequence. 106 | * `xtr` can also parse the `mod` keyword, and easily parse all the files in a crate. 107 | * Finally, `xtr` can also parse the more advanced syntax within the `tr!` macro. 108 | 109 | # Licence 110 | 111 | * The `tr` crate is licensed under the [MIT](https://opensource.org/licenses/MIT) license. 112 | 113 | * The `xtr` program is a binary used only for development and is in the 114 | [GNU Affero General Public License (AGPL)](https://www.gnu.org/licenses/agpl-3.0.en.html). 115 | 116 | # Contribution 117 | 118 | Contributions are welcome. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion 119 | in this crate by you, should be licensed under the MIT license. 120 | 121 | ## Request for feedback 122 | 123 | Please fill your suggestions as issues. Or help by commenting on https://github.com/woboq/tr/issues/1 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /xtr/src/crate_visitor.rs: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2018 Olivier Goffart 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU Affero General Public License as 5 | published by the Free Software Foundation, either version 3 of the 6 | License, or (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License 14 | along with this program. If not, see . 15 | */ 16 | 17 | use anyhow::Error; 18 | use std::fs::File; 19 | use std::io::Read; 20 | use std::mem::swap; 21 | use std::path::{Path, PathBuf}; 22 | use syn::ext::IdentExt; 23 | use syn::visit::Visit; 24 | 25 | /** 26 | * Parse a crate to visit every module. The `visitor` function will be called for every file 27 | * in the crate. The argument of the visitor are: the path of the file, the full string content 28 | * of the file, and a parsed syn::File representation of this file. 29 | */ 30 | pub fn visit_crate, V>(crate_root: P, visitor: V) -> Result<(), Error> 31 | where 32 | V: FnMut(&PathBuf, &str, &syn::File) -> Result<(), Error>, 33 | { 34 | let mut parser = Parser { 35 | current_path: PathBuf::default(), 36 | mod_dir: PathBuf::default(), 37 | mod_error: None, 38 | mod_visitor: visitor, 39 | }; 40 | parser.parse_mod(crate_root) 41 | } 42 | 43 | struct Parser { 44 | current_path: PathBuf, // The current file being parsed 45 | mod_dir: PathBuf, 46 | mod_error: Option, // An error occuring while visiting the modules 47 | mod_visitor: ModVisitor, 48 | } 49 | 50 | impl Parser 51 | where 52 | ModVisitor: FnMut(&PathBuf, &str, &syn::File) -> Result<(), Error>, 53 | { 54 | fn parse_mod>(&mut self, mod_path: P) -> Result<(), Error> { 55 | let mut s = String::new(); 56 | let mut f = File::open(&mod_path)?; 57 | f.read_to_string(&mut s)?; 58 | 59 | let fi = syn::parse_file(&s)?; 60 | 61 | let mut current_path = mod_path.as_ref().into(); 62 | let mut mod_dir = mod_path.as_ref().parent().unwrap().into(); 63 | 64 | swap(&mut self.current_path, &mut current_path); 65 | swap(&mut self.mod_dir, &mut mod_dir); 66 | 67 | self.visit_file(&fi); 68 | if let Some(err) = self.mod_error.take() { 69 | return Err(err); 70 | } 71 | 72 | (self.mod_visitor)(&self.current_path, &s, &fi)?; 73 | 74 | swap(&mut self.current_path, &mut current_path); 75 | swap(&mut self.mod_dir, &mut mod_dir); 76 | 77 | Ok(()) 78 | } 79 | } 80 | 81 | impl<'ast, ModVisitor> Visit<'ast> for Parser 82 | where 83 | ModVisitor: FnMut(&PathBuf, &str, &syn::File) -> Result<(), Error>, 84 | { 85 | fn visit_item_mod(&mut self, item: &'ast syn::ItemMod) { 86 | if self.mod_error.is_some() { 87 | return; 88 | } 89 | 90 | if item.content.is_some() { 91 | let mut parent = self.mod_dir.join(item.ident.to_string()); 92 | swap(&mut self.mod_dir, &mut parent); 93 | syn::visit::visit_item_mod(self, item); 94 | swap(&mut self.mod_dir, &mut parent); 95 | return; 96 | } 97 | 98 | // Determine the path of the inner module's file 99 | for attr in &item.attrs { 100 | match &attr.meta { 101 | syn::Meta::NameValue(syn::MetaNameValue { 102 | path, 103 | value: 104 | syn::Expr::Lit(syn::ExprLit { 105 | lit: syn::Lit::Str(s), 106 | .. 107 | }), 108 | .. 109 | }) if path.is_ident("path") => { 110 | let mod_path = self.mod_dir.join(s.value()); 111 | return self 112 | .parse_mod(mod_path) 113 | .unwrap_or_else(|err| self.mod_error = Some(err)); 114 | } 115 | _ => {} 116 | } 117 | } 118 | 119 | let mod_name = item.ident.unraw().to_string(); 120 | let mut subdir = self.mod_dir.join(mod_name.clone()); 121 | subdir.push("mod.rs"); 122 | if subdir.is_file() { 123 | return self 124 | .parse_mod(subdir) 125 | .unwrap_or_else(|err| self.mod_error = Some(err)); 126 | } 127 | let adjacent = self.mod_dir.join(format!("{}.rs", mod_name)); 128 | if adjacent.is_file() { 129 | return self 130 | .parse_mod(adjacent) 131 | .unwrap_or_else(|err| self.mod_error = Some(err)); 132 | } 133 | 134 | let mut nested_mod_dir = self.current_path.clone(); 135 | nested_mod_dir.pop(); 136 | let subdir = self.current_path.file_name().unwrap().to_str().unwrap(); 137 | if let Some(without_suffix) = subdir.strip_suffix(".rs") { 138 | // Support the case when "mod bar;" in src/foo.rs means an src/foo/bar.rs file. 139 | let adjacent = nested_mod_dir.join(format!("{}/{}.rs", without_suffix, mod_name)); 140 | if adjacent.is_file() { 141 | return self 142 | .parse_mod(adjacent) 143 | .unwrap_or_else(|err| self.mod_error = Some(err)); 144 | } 145 | let adjacent_mod = nested_mod_dir 146 | .join(without_suffix) 147 | .join(&mod_name) 148 | .join("mod.rs"); 149 | if adjacent_mod.is_file() { 150 | return self 151 | .parse_mod(adjacent_mod) 152 | .unwrap_or_else(|err| self.mod_error = Some(err)); 153 | } 154 | } 155 | 156 | panic!( 157 | "No file with module definition for `mod {}` in file {:?}", 158 | mod_name, self.current_path 159 | ); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /xtr/src/main.rs: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2018 Olivier Goffart 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU Affero General Public License as 5 | published by the Free Software Foundation, either version 3 of the 6 | License, or (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License 14 | along with this program. If not, see . 15 | */ 16 | 17 | use quote::ToTokens; 18 | use tr::{tr, tr_init}; 19 | 20 | use anyhow::{anyhow, Error}; 21 | use clap::{arg, command, Arg, ArgAction}; 22 | use std::collections::HashMap; 23 | use std::str::FromStr; 24 | 25 | mod crate_visitor; 26 | mod extract_messages; 27 | mod generator; 28 | 29 | #[derive(Clone, Debug)] 30 | pub enum SpecArg { 31 | MsgId(u32), 32 | Context(u32), 33 | } 34 | 35 | #[derive(Default, Clone, Debug)] 36 | pub struct Spec { 37 | pub args: Vec, 38 | pub comment: Option, 39 | pub argnum: Option, 40 | } 41 | 42 | #[derive(Debug, Clone, PartialEq, Eq)] 43 | struct Location { 44 | pub file: std::path::PathBuf, 45 | pub line: usize, 46 | } 47 | 48 | #[derive(Debug, Clone, Default, Eq, PartialEq, Hash)] 49 | pub struct MessageKey(String, String); 50 | 51 | #[derive(Debug, Clone, Default, PartialEq, Eq)] 52 | pub struct Message { 53 | msgctxt: Option, 54 | msgid: String, 55 | plural: Option, 56 | locations: Vec, 57 | comments: Option, 58 | /// that's just keeping the count, so they can be sorted 59 | index: usize, 60 | } 61 | 62 | /// How much [`Message`] location information to include in the 63 | /// output. 64 | #[derive(PartialEq, Debug)] 65 | pub enum AddLocation { 66 | /// Format the locations output as ‘#: filename:line’ 67 | /// This is the default. 68 | Full, 69 | /// Format the locations output as ‘#: filename` 70 | File, 71 | /// Don't include the message locations. 72 | Never, 73 | } 74 | 75 | impl FromStr for AddLocation { 76 | type Err = anyhow::Error; 77 | 78 | /// Create an [`AddLocation`] from a &str. Valid inputs 79 | /// are: "full", "file" or "never". 80 | fn from_str(s: &str) -> Result { 81 | match s { 82 | "full" => Ok(AddLocation::Full), 83 | "file" => Ok(AddLocation::File), 84 | "never" => Ok(AddLocation::Never), 85 | _ => Err(anyhow!( 86 | "\"{0}\" is not a valid --add-location option. Valid \ 87 | options are \"full\", \"file\" or \"never\".", 88 | s 89 | )), 90 | } 91 | } 92 | } 93 | 94 | pub struct OutputDetails { 95 | omit_header: bool, 96 | copyright_holder: Option, 97 | package_name: Option, 98 | package_version: Option, 99 | bugs_address: Option, 100 | charset: String, 101 | add_location: AddLocation, 102 | } 103 | 104 | fn main() -> Result<(), Error> { 105 | tr_init!(concat!(env!("CARGO_MANIFEST_DIR"), "/lang/")); 106 | 107 | // The options are made to be compatible with xgetext options 108 | let matches = command!() 109 | .about(tr!( 110 | "Extract strings from a rust crate to be translated with gettext" 111 | )) 112 | .arg( 113 | Arg::new("domain") 114 | .short('d') 115 | .long("default-domain") 116 | .value_name("name") 117 | .help(tr!("Use name.po for output (instead of messages.po)")), 118 | ) 119 | .arg(arg!(OUTPUT: -o --output ).help(tr!( 120 | "Write output to specified file (instead of messages.po)." 121 | ))) 122 | .arg( 123 | arg!(KEYWORDS: -k --keywords ) 124 | .action(ArgAction::Append) 125 | .help(tr!( 126 | // documentation for keywordspec goes here 127 | "Specify keywordspec as an additional keyword to be looked for. \ 128 | Refer to the xgettext documentation for more info." 129 | )), 130 | ) 131 | .arg( 132 | Arg::new("omit-header") 133 | .long("omit-header") 134 | .action(ArgAction::SetTrue) 135 | .help(tr!(r#"Don’t write header with ‘msgid ""’ entry"#)), 136 | ) 137 | .arg( 138 | Arg::new("copyright-holder") 139 | .long("copyright-holder") 140 | .value_name("string") 141 | .help(tr!("Set the copyright holder in the output.")), 142 | ) 143 | .arg( 144 | Arg::new("package-name") 145 | .long("package-name") 146 | .value_name("package") 147 | .help(tr!("Set the package name in the header of the output.")), 148 | ) 149 | .arg( 150 | Arg::new("package-version") 151 | .long("package-version") 152 | .value_name("version") 153 | .help(tr!("Set the package version in the header of the output.")), 154 | ) 155 | .arg( 156 | Arg::new("msgid-bugs-address") 157 | .long("msgid-bugs-address") 158 | .value_name("email@address") 159 | .help(tr!( 160 | "Set the reporting address for msgid bugs. This is the email address \ 161 | or URL to which the translators shall report bugs in the untranslated strings" 162 | )), 163 | ) 164 | .arg( 165 | Arg::new("charset") 166 | .long("charset") 167 | .value_name("encoding") 168 | .default_value("UTF-8") 169 | .help(tr!( 170 | "The encoding used for the characters in the POT file's locale." 171 | )), 172 | ) 173 | .arg( 174 | Arg::new("add-location") 175 | .long("add-location") 176 | .short('n') 177 | .help(tr!( 178 | "How much message location information to include in the output. \ 179 | (default). If the type is ‘full’ (the default), it generates the \ 180 | lines with both file name and line number: ‘#: filename:line’. \ 181 | If it is ‘file’, the line number part is omitted: ‘#: filename’. \ 182 | If it is ‘never’, nothing is generated." 183 | )) 184 | .value_name("type") 185 | .value_parser(["full", "file", "never"]) 186 | .default_value("full"), 187 | ) 188 | .arg( 189 | Arg::new("INPUT") 190 | // documentation for the input 191 | .help(tr!("Main rust files to parse (will recurse into modules)")) 192 | .required(true) 193 | .action(ArgAction::Append), 194 | ) 195 | .get_matches(); 196 | 197 | let keywords = matches 198 | .get_occurrences("KEYWORDS") 199 | .map(|x| x.flatten().map(String::as_str).collect()) 200 | .unwrap_or_else(|| { 201 | vec![ 202 | "tr", 203 | "gettext", 204 | "dgettext:2", 205 | "dcgettext:2", 206 | "ngettext:1,2", 207 | "dngettext:2,3", 208 | "dcngettext:2,3", 209 | "gettext_noop", 210 | "pgettext:1c,2", 211 | "dpgettext:2c,3", 212 | "dcpgettext:2c,3", 213 | "npgettext:1c,2,3", 214 | "dnpgettext:2c,3,4", 215 | "dcnpgettext:2c,3,4", 216 | ] 217 | }); 218 | let mut specs = HashMap::new(); 219 | for k in keywords { 220 | if let Some(colon) = k.find(':') { 221 | let (name, desc) = k.split_at(colon); 222 | let spec = desc[1..] 223 | .split(',') 224 | .map(|d| { 225 | if let Some(d) = d.strip_suffix('c') { 226 | return SpecArg::Context(d.parse().expect("invalid keyword spec")); 227 | } 228 | SpecArg::MsgId(d.parse().expect("invalid keyword spec")) 229 | // TODO: comment or argnum 230 | }) 231 | .collect(); 232 | specs.insert( 233 | name.to_owned(), 234 | Spec { 235 | args: spec, 236 | comment: None, 237 | argnum: None, 238 | }, 239 | ); 240 | } else { 241 | specs.insert(k.to_owned(), Spec::default()); 242 | } 243 | } 244 | 245 | let mut results = HashMap::new(); 246 | 247 | let inputs = matches 248 | .get_occurrences::("INPUT") 249 | .expect("Missing crate root"); 250 | for i in inputs.flatten() { 251 | crate_visitor::visit_crate(i, |path, source, file| { 252 | extract_messages::extract_messages( 253 | &mut results, 254 | &specs, 255 | file.into_token_stream(), 256 | source, 257 | path, 258 | ) 259 | })?; 260 | } 261 | 262 | let od = OutputDetails { 263 | omit_header: matches.get_flag("omit-header"), 264 | copyright_holder: matches.get_one("copyright-holder").cloned(), 265 | package_name: matches.get_one("package-name").cloned(), 266 | package_version: matches.get_one("package-version").cloned(), 267 | bugs_address: matches.get_one("msgid-bugs-address").cloned(), 268 | charset: matches 269 | .get_one::("charset") 270 | .expect("expected charset to have a default value") 271 | .clone(), 272 | add_location: AddLocation::from_str( 273 | matches 274 | .get_one::("add-location") 275 | .expect("expected add-location to have a default value"), 276 | ) 277 | .expect("expected add-location to be a valid value"), 278 | }; 279 | 280 | let mut messages: Vec<_> = results.values().collect(); 281 | messages.sort_by_key(|m| m.index); 282 | generator::generate( 283 | matches.get_one("OUTPUT").cloned().unwrap_or_else(|| { 284 | format!( 285 | "{}.po", 286 | matches 287 | .get_one("domain") 288 | .map(String::as_str) 289 | .unwrap_or("messages") 290 | ) 291 | }), 292 | od, 293 | messages, 294 | )?; 295 | 296 | Ok(()) 297 | } 298 | -------------------------------------------------------------------------------- /xtr/src/extract_messages.rs: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2018 Olivier Goffart 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU Affero General Public License as 5 | published by the Free Software Foundation, either version 3 of the 6 | License, or (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License 14 | along with this program. If not, see . 15 | */ 16 | 17 | use super::{Message, MessageKey, Spec, SpecArg}; 18 | use anyhow::Error; 19 | use proc_macro2::{Span, TokenStream, TokenTree}; 20 | use std::collections::HashMap; 21 | use std::path::PathBuf; 22 | 23 | pub fn extract_messages( 24 | results: &mut HashMap, 25 | specs: &HashMap, 26 | stream: TokenStream, 27 | source: &str, 28 | path: &PathBuf, 29 | ) -> Result<(), Error> { 30 | let mut ex = Extractor { 31 | results, 32 | specs, 33 | path, 34 | source_lines: split_lines(source), 35 | }; 36 | ex.extract_messages(stream) 37 | } 38 | 39 | fn split_lines(source: &str) -> Vec<&str> { 40 | source.split('\n').collect() 41 | } 42 | 43 | #[allow(dead_code)] 44 | struct Extractor<'a> { 45 | results: &'a mut HashMap, 46 | specs: &'a HashMap, 47 | path: &'a PathBuf, 48 | source_lines: Vec<&'a str>, 49 | } 50 | 51 | impl<'a> Extractor<'a> { 52 | fn extract_messages(&mut self, stream: TokenStream) -> Result<(), Error> { 53 | let mut token_iter = stream.into_iter().peekable(); 54 | while let Some(token) = token_iter.next() { 55 | match token { 56 | TokenTree::Group(group) => { 57 | self.extract_messages(group.stream())?; 58 | } 59 | TokenTree::Ident(ident) => { 60 | if let Some(spec) = self.specs.get(&ident.to_string()) { 61 | let mut skip = false; 62 | if let Some(TokenTree::Punct(punct)) = token_iter.peek() { 63 | if punct.to_string() == "!" { 64 | // allow macros 65 | skip = true; 66 | } 67 | } 68 | if skip { 69 | token_iter.next(); 70 | } 71 | 72 | if let Some(TokenTree::Group(group)) = token_iter.peek() { 73 | self.found_string(spec, group.stream(), ident.span()); 74 | } 75 | } 76 | } 77 | _ => {} 78 | } 79 | } 80 | Ok(()) 81 | } 82 | 83 | fn found_string(&mut self, spec: &Spec, stream: TokenStream, ident_span: Span) { 84 | let mut token_iter = stream.into_iter().peekable(); 85 | 86 | let mut msgctxt: Option = None; 87 | let mut msgid: Option = None; 88 | let mut plural: Option = None; 89 | 90 | if spec.args.is_empty() { 91 | let mut literal = if let Some(TokenTree::Literal(literal)) = token_iter.next() { 92 | literal 93 | } else { 94 | return; // syntax error 95 | }; 96 | 97 | let mut token = token_iter.next(); 98 | if let Some(TokenTree::Punct(punct)) = token.clone() { 99 | if punct.to_string() == "=" { 100 | token = token_iter.next(); 101 | if let Some(TokenTree::Punct(punct)) = token.clone() { 102 | if punct.to_string() == ">" { 103 | if let Some(TokenTree::Literal(lit)) = token_iter.next() { 104 | msgctxt = literal_to_string(&literal); 105 | literal = lit; 106 | token = token_iter.next(); 107 | } else { 108 | return; // syntax error 109 | } 110 | } 111 | } 112 | } 113 | } 114 | msgid = Some(literal.clone()); 115 | if let Some(TokenTree::Punct(punct)) = token { 116 | if punct.to_string() == "|" { 117 | if let Some(TokenTree::Literal(lit)) = token_iter.next() { 118 | plural = literal_to_string(&lit); 119 | } 120 | } 121 | } 122 | } else { 123 | let mut args = Vec::new(); 124 | 'm: loop { 125 | if let Some(TokenTree::Literal(literal)) = token_iter.peek() { 126 | args.push(Some(literal.clone())); 127 | } else { 128 | args.push(None); 129 | } 130 | 131 | // skip to the comma 132 | for token in token_iter.by_ref() { 133 | if let TokenTree::Punct(punct) = token { 134 | if punct.to_string() == "," { 135 | continue 'm; 136 | } 137 | } 138 | } 139 | break; 140 | } 141 | 142 | if let Some(num) = spec.argnum { 143 | if args.len() != num as usize { 144 | return; 145 | } 146 | } 147 | for a in spec.args.iter() { 148 | match a { 149 | SpecArg::MsgId(i) => { 150 | if msgid.is_some() { 151 | plural = args 152 | .get(*i as usize - 1) 153 | .and_then(|x| x.as_ref()) 154 | .and_then(literal_to_string); 155 | } else if let Some(lit) = args.get(*i as usize - 1) { 156 | msgid = lit.clone(); 157 | } 158 | } 159 | SpecArg::Context(i) => { 160 | msgctxt = args 161 | .get(*i as usize - 1) 162 | .and_then(|x| x.as_ref()) 163 | .and_then(literal_to_string); 164 | } 165 | } 166 | } 167 | } 168 | 169 | if let Some(lit) = msgid { 170 | if let Some(msgid) = literal_to_string(&lit) { 171 | let key = MessageKey(msgid.clone(), msgctxt.clone().unwrap_or_default()); 172 | let index = self.results.len(); 173 | let message = self.results.entry(key).or_insert_with(|| Message { 174 | msgctxt, 175 | msgid, 176 | index, 177 | ..Default::default() 178 | }); 179 | if plural.is_some() { 180 | message.plural = plural; 181 | } 182 | 183 | // Extract the location and the comments from lit and merge it into message 184 | { 185 | let span = lit.span(); 186 | let line = span.start().line; 187 | if line > 0 { 188 | message.locations.push(super::Location { 189 | file: self.path.clone(), 190 | line, 191 | }); 192 | } 193 | 194 | let mut comments = get_comment_before_line(&self.source_lines, line); 195 | if comments.is_none() { 196 | let ident_line = ident_span.start().line; 197 | if ident_line != line { 198 | comments = get_comment_before_line(&self.source_lines, ident_line); 199 | } 200 | } 201 | message.comments = comments; 202 | } 203 | } 204 | } 205 | } 206 | } 207 | 208 | fn literal_to_string(lit: &proc_macro2::Literal) -> Option { 209 | match syn::parse_str::(&lit.to_string()) { 210 | Ok(lit) => Some(lit.value()), 211 | Err(_) => None, 212 | } 213 | } 214 | 215 | fn get_comment_before_line(source_lines: &Vec<&str>, mut line: usize) -> Option { 216 | let mut result = None; 217 | line -= 1; 218 | while line > 1 { 219 | line -= 1; 220 | let line_str = source_lines.get(line).unwrap().trim(); 221 | if line_str.starts_with("//") { 222 | let line_str = line_str.trim_start_matches('/').trim_start(); 223 | result = if let Some(ref string) = result { 224 | Some(format!("{}\n{}", line_str, string)) 225 | } else { 226 | Some(line_str.to_owned()) 227 | } 228 | } else { 229 | break; 230 | } 231 | } 232 | result 233 | } 234 | 235 | #[test] 236 | fn test_extract_messages() { 237 | fn make(msg: &str, p: &str, ctx: &str, co: &str, loc: &[usize]) -> Message { 238 | use super::Location; 239 | let opt = |x: &str| { 240 | if x.is_empty() { 241 | None 242 | } else { 243 | Some(x.to_owned()) 244 | } 245 | }; 246 | let locations = loc 247 | .iter() 248 | .map(|l| Location { 249 | file: "myfile.rs".to_owned().into(), 250 | line: *l, 251 | }) 252 | .collect(); 253 | Message { 254 | msgctxt: opt(ctx), 255 | msgid: msg.into(), 256 | plural: opt(p), 257 | locations, 258 | comments: opt(co), 259 | index: 0, 260 | } 261 | } 262 | 263 | let source = r##"fn foo() { 264 | // comment 1 265 | let x = tr!("Message 1"); 266 | // comment does not count 267 | 268 | // comment 2 269 | let x = tr!("ctx" => "Message 2"); 270 | // comment does not count 271 | 272 | let x = tr!("Message 3" | "Messages 3" % x); 273 | 274 | // comment 4 275 | let x = tr!("ctx4" => "Message 4" | "Messages 4" % x); 276 | 277 | foobar1((foo(bar, boo)),2, "foobar1"); 278 | foobar2(1,"foobar2", "foobar2s", f("5", "4"), "ctx"); 279 | 280 | //recursive 281 | let x = tr!("rec1 {}", tr!("rec2")); 282 | 283 | let x = tr!(r#"raw\"ctx""# => r#"\raw\"#); 284 | 285 | // comment does not count : xgettext takes the comment next to the string 286 | let x = tr!( 287 | //multi line 288 | "multi-line \ 289 | second line" 290 | ); 291 | 292 | let d = tr!("dup1"); 293 | let d = tr!("ctx" => "dup1"); 294 | let d = tr!("dup1"); 295 | let d = tr!("ctx" => "dup1"); 296 | 297 | // macro and string on different line 298 | let x = tr!( 299 | "x" 300 | ); 301 | 302 | }"##; 303 | 304 | let r = vec![ 305 | make("Message 1", "", "", "comment 1", &[3]), 306 | make("Message 2", "", "ctx", "comment 2", &[7]), 307 | make("Message 3", "Messages 3", "", "", &[10]), 308 | make("Message 4", "Messages 4", "ctx4", "comment 4", &[13]), 309 | make("foobar1", "", "", "", &[15]), 310 | make("foobar2", "foobar2s", "ctx", "", &[16]), 311 | make("rec1 {}", "", "", "recursive", &[19]), 312 | make("rec2", "", "", "recursive", &[19]), 313 | make(r#"\raw\"#, "", r#"raw\"ctx""#, "", &[21]), 314 | make("multi-line second line", "", "", "multi line", &[26]), 315 | make("dup1", "", "", "", &[30, 32]), 316 | make("dup1", "", "ctx", "", &[31, 33]), 317 | make("x", "", "", "macro and string on different line", &[37]), 318 | ]; 319 | 320 | let specs = [ 321 | ("tr".into(), Default::default()), 322 | ( 323 | "foobar1".into(), 324 | Spec { 325 | args: vec![SpecArg::MsgId(3)], 326 | ..Default::default() 327 | }, 328 | ), 329 | ( 330 | "foobar2".into(), 331 | Spec { 332 | args: vec![SpecArg::MsgId(2), SpecArg::MsgId(3), SpecArg::Context(5)], 333 | ..Default::default() 334 | }, 335 | ), 336 | ] 337 | .iter() 338 | .cloned() 339 | .collect(); 340 | 341 | let mut results = HashMap::new(); 342 | 343 | let mut ex = Extractor { 344 | results: &mut results, 345 | specs: &specs, 346 | path: &"myfile.rs".to_owned().into(), 347 | source_lines: split_lines(source), 348 | }; 349 | use std::str::FromStr; 350 | ex.extract_messages(proc_macro2::TokenStream::from_str(source).unwrap()) 351 | .unwrap(); 352 | let mut messages: Vec<_> = ex.results.values().collect(); 353 | messages.sort_by_key(|m| m.index); 354 | let mlen = messages.len(); 355 | for (a, b) in r.iter().zip(messages) { 356 | let mut b = b.clone(); 357 | b.index = 0; 358 | assert_eq!(*a, b); 359 | /*assert_eq!(a.msgid, b.msgid); 360 | assert_eq!(a.plural, b.plural); 361 | assert_eq!(a.msgctxt, b.msgctxt); 362 | assert_eq!(a.comment, b.comment); 363 | assert_eq!(a.locations, b.locations);*/ 364 | } 365 | assert_eq!(r.len(), mlen); 366 | } 367 | -------------------------------------------------------------------------------- /tr/src/lib.rs: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2018 Olivier Goffart 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 4 | associated documentation files (the "Software"), to deal in the Software without restriction, 5 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, 7 | subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial 10 | portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 13 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 14 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 15 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] 20 | 21 | //! # Internationalisation helper 22 | //! 23 | //! This crate maily expose a macro that wraps gettext in a convinient ways. 24 | //! See the documentation of the [tr! macro](tr!). 25 | //! 26 | //! To translate a rust crate, simply wrap your string within the [`tr!` macro](tr!). 27 | //! One can then use the `xtr` binary to extract all the translatable from a crate in a `.po` 28 | //! file. GNU gettext tools can be used to process and translate these strings. 29 | //! 30 | //! The tr! macro also support support rust-like formating. 31 | //! 32 | //! Example: 33 | //! 34 | //! ``` 35 | //! #[macro_use] 36 | //! extern crate tr; 37 | //! fn main() { 38 | //! // use the tr_init macro to tell gettext where to look for translations 39 | //! # #[cfg(feature = "gettext-rs")] 40 | //! tr_init!("/usr/share/locale/"); 41 | //! let folder = if let Some(folder) = std::env::args().nth(1) { 42 | //! folder 43 | //! } else { 44 | //! println!("{}", tr!("Please give folder name")); 45 | //! return; 46 | //! }; 47 | //! match std::fs::read_dir(&folder) { 48 | //! Err(e) => { 49 | //! println!("{}", tr!("Could not read directory '{}'\nError: {}", 50 | //! folder, e)); 51 | //! } 52 | //! Ok(r) => { 53 | //! // Singular/plural formating 54 | //! println!("{}", tr!( 55 | //! "The directory {} has one file" | "The directory {} has {n} files" % r.count(), 56 | //! folder 57 | //! )); 58 | //! } 59 | //! } 60 | //! } 61 | //! ``` 62 | //! 63 | //! # Optional Features 64 | //! 65 | //! You can change which crate is used as a backend for the translation by setting the features 66 | //! 67 | //! - **`gettext-rs`** *(enabled by default)* - This crate wraps the gettext C library 68 | //! - **`gettext`** - A rust re-implementation of gettext. That crate does not take care of loading the 69 | //! right .mo files, so one must use the [`set_translator!`] macro with a 70 | //! `gettext::Catalog` object 71 | //! 72 | //! Additionally, this crate permits loading from `.po` or `.mo` files directly via the [`PoTranslator`] and 73 | //! [`MoTranslator`] types, guarded beind the respective **`mo-translator`** and **`po-translator`** features. 74 | 75 | #[cfg(any(feature = "po-translator", feature = "mo-translator"))] 76 | mod rspolib_translator; 77 | #[cfg(any(feature = "po-translator", feature = "mo-translator"))] 78 | pub use rspolib_translator::MoPoTranslatorLoadError; 79 | 80 | #[cfg(feature = "mo-translator")] 81 | pub use rspolib_translator::MoTranslator; 82 | #[cfg(feature = "po-translator")] 83 | pub use rspolib_translator::PoTranslator; 84 | 85 | use std::borrow::Cow; 86 | 87 | #[doc(hidden)] 88 | pub mod runtime_format { 89 | //! poor man's dynamic formater. 90 | //! 91 | //! This module create a simple dynamic formater which replaces '{}' or '{n}' with the 92 | //! argument. 93 | //! 94 | //! This does not use the runtime_fmt crate because it needs nightly compiler 95 | //! 96 | //! TODO: better error reporting and support for more replacement option 97 | 98 | /// Converts the result of the runtime_format! macro into the final String 99 | pub fn display_string(format_str: &str, args: &[(&str, &dyn ::std::fmt::Display)]) -> String { 100 | use ::std::fmt::Write; 101 | let fmt_len = format_str.len(); 102 | let mut res = String::with_capacity(2 * fmt_len); 103 | let mut arg_idx = 0; 104 | let mut pos = 0; 105 | while let Some(mut p) = format_str[pos..].find(['{', '}']) { 106 | if fmt_len - pos < p + 1 { 107 | break; 108 | } 109 | p += pos; 110 | 111 | // Skip escaped } 112 | if format_str.get(p..=p) == Some("}") { 113 | res.push_str(&format_str[pos..=p]); 114 | if format_str.get(p + 1..=p + 1) == Some("}") { 115 | pos = p + 2; 116 | } else { 117 | // FIXME! this is an error, it should be reported ('}' must be escaped) 118 | pos = p + 1; 119 | } 120 | continue; 121 | } 122 | 123 | // Skip escaped { 124 | if format_str.get(p + 1..=p + 1) == Some("{") { 125 | res.push_str(&format_str[pos..=p]); 126 | pos = p + 2; 127 | continue; 128 | } 129 | 130 | // Find the argument 131 | let end = if let Some(end) = format_str[p..].find('}') { 132 | end + p 133 | } else { 134 | // FIXME! this is an error, it should be reported 135 | res.push_str(&format_str[pos..=p]); 136 | pos = p + 1; 137 | continue; 138 | }; 139 | let argument = format_str[p + 1..end].trim(); 140 | let pa = if p == end - 1 { 141 | arg_idx += 1; 142 | arg_idx - 1 143 | } else if let Ok(n) = argument.parse::() { 144 | n 145 | } else if let Some(p) = args.iter().position(|x| x.0 == argument) { 146 | p 147 | } else { 148 | // FIXME! this is an error, it should be reported 149 | res.push_str(&format_str[pos..end]); 150 | pos = end; 151 | continue; 152 | }; 153 | 154 | // format the part before the '{' 155 | res.push_str(&format_str[pos..p]); 156 | if let Some(a) = args.get(pa) { 157 | write!(&mut res, "{}", a.1) 158 | .expect("a Display implementation returned an error unexpectedly"); 159 | } else { 160 | // FIXME! this is an error, it should be reported 161 | res.push_str(&format_str[p..=end]); 162 | } 163 | pos = end + 1; 164 | } 165 | res.push_str(&format_str[pos..]); 166 | res 167 | } 168 | 169 | #[doc(hidden)] 170 | /// runtime_format! macro. See runtime_format module documentation. 171 | #[macro_export] 172 | macro_rules! runtime_format { 173 | ($fmt:expr) => {{ 174 | // TODO! check if 'fmt' does not have {} 175 | String::from($fmt) 176 | }}; 177 | ($fmt:expr, $($tail:tt)* ) => {{ 178 | $crate::runtime_format::display_string( 179 | AsRef::as_ref(&$fmt), 180 | $crate::runtime_format!(@parse_args [] $($tail)*), 181 | ) 182 | }}; 183 | 184 | (@parse_args [$($args:tt)*]) => { &[ $( $args ),* ] }; 185 | (@parse_args [$($args:tt)*] $name:ident) => { 186 | $crate::runtime_format!(@parse_args [$($args)* (stringify!($name) , &$name)]) 187 | }; 188 | (@parse_args [$($args:tt)*] $name:ident, $($tail:tt)*) => { 189 | $crate::runtime_format!(@parse_args [$($args)* (stringify!($name) , &$name)] $($tail)*) 190 | }; 191 | (@parse_args [$($args:tt)*] $name:ident = $e:expr) => { 192 | $crate::runtime_format!(@parse_args [$($args)* (stringify!($name) , &$e)]) 193 | }; 194 | (@parse_args [$($args:tt)*] $name:ident = $e:expr, $($tail:tt)*) => { 195 | $crate::runtime_format!(@parse_args [$($args)* (stringify!($name) , &$e)] $($tail)*) 196 | }; 197 | (@parse_args [$($args:tt)*] $e:expr) => { 198 | $crate::runtime_format!(@parse_args [$($args)* ("" , &$e)]) 199 | }; 200 | (@parse_args [$($args:tt)*] $e:expr, $($tail:tt)*) => { 201 | $crate::runtime_format!(@parse_args [$($args)* ("" , &$e)] $($tail)*) 202 | }; 203 | } 204 | 205 | #[cfg(test)] 206 | mod tests { 207 | #[test] 208 | fn test_format() { 209 | assert_eq!(runtime_format!("Hello"), "Hello"); 210 | assert_eq!(runtime_format!("Hello {}!", "world"), "Hello world!"); 211 | assert_eq!(runtime_format!("Hello {0}!", "world"), "Hello world!"); 212 | assert_eq!( 213 | runtime_format!("Hello -{1}- -{0}-", 40 + 5, "World"), 214 | "Hello -World- -45-" 215 | ); 216 | assert_eq!( 217 | runtime_format!(format!("Hello {{}}!"), format!("{}", "world")), 218 | "Hello world!" 219 | ); 220 | assert_eq!( 221 | runtime_format!("Hello -{}- -{}-", 40 + 5, "World"), 222 | "Hello -45- -World-" 223 | ); 224 | assert_eq!( 225 | runtime_format!("Hello {name}!", name = "world"), 226 | "Hello world!" 227 | ); 228 | let name = "world"; 229 | assert_eq!(runtime_format!("Hello {name}!", name), "Hello world!"); 230 | assert_eq!(runtime_format!("{} {}!", "Hello", name), "Hello world!"); 231 | assert_eq!(runtime_format!("{} {name}!", "Hello", name), "Hello world!"); 232 | assert_eq!( 233 | runtime_format!("{0} {name}!", "Hello", name = "world"), 234 | "Hello world!" 235 | ); 236 | 237 | assert_eq!( 238 | runtime_format!("Hello {{0}} {}", "world"), 239 | "Hello {0} world" 240 | ); 241 | } 242 | } 243 | } 244 | 245 | /// This trait can be implemented by object that can provide a backend for the translation 246 | /// 247 | /// The backend is only responsable to provide a matching string, the formatting is done 248 | /// using this string. 249 | /// 250 | /// The translator for a crate can be set with the [`set_translator!`] macro 251 | pub trait Translator: Send + Sync { 252 | fn translate<'a>(&'a self, string: &'a str, context: Option<&'a str>) -> Cow<'a, str>; 253 | fn ntranslate<'a>( 254 | &'a self, 255 | n: u64, 256 | singular: &'a str, 257 | plural: &'a str, 258 | context: Option<&'a str>, 259 | ) -> Cow<'a, str>; 260 | } 261 | 262 | impl Translator for std::sync::Arc { 263 | fn translate<'a>( 264 | &'a self, 265 | string: &'a str, 266 | context: Option<&'a str>, 267 | ) -> std::borrow::Cow<'a, str> { 268 | ::translate(self, string, context) 269 | } 270 | 271 | fn ntranslate<'a>( 272 | &'a self, 273 | n: u64, 274 | singular: &'a str, 275 | plural: &'a str, 276 | context: Option<&'a str>, 277 | ) -> std::borrow::Cow<'a, str> { 278 | ::ntranslate(self, n, singular, plural, context) 279 | } 280 | } 281 | 282 | #[doc(hidden)] 283 | pub mod internal { 284 | 285 | use super::Translator; 286 | use std::{borrow::Cow, collections::HashMap, sync::LazyLock, sync::RwLock}; 287 | 288 | static TRANSLATORS: LazyLock>>> = 289 | LazyLock::new(Default::default); 290 | 291 | pub fn with_translator(module: &'static str, func: impl FnOnce(&dyn Translator) -> T) -> T { 292 | let domain = domain_from_module(module); 293 | let def = DefaultTranslator(domain); 294 | func( 295 | TRANSLATORS 296 | .read() 297 | .unwrap() 298 | .get(domain) 299 | .map(|x| &**x) 300 | .unwrap_or(&def), 301 | ) 302 | } 303 | 304 | fn domain_from_module(module: &str) -> &str { 305 | module.split("::").next().unwrap_or(module) 306 | } 307 | 308 | #[cfg(feature = "gettext-rs")] 309 | fn mangle_context(ctx: &str, s: &str) -> String { 310 | format!("{}\u{4}{}", ctx, s) 311 | } 312 | #[cfg(feature = "gettext-rs")] 313 | fn demangle_context(r: String) -> String { 314 | if let Some(x) = r.split('\u{4}').next_back() { 315 | return x.to_owned(); 316 | } 317 | r 318 | } 319 | 320 | struct DefaultTranslator(&'static str); 321 | 322 | #[cfg(feature = "gettext-rs")] 323 | impl Translator for DefaultTranslator { 324 | fn translate<'a>(&'a self, string: &'a str, context: Option<&'a str>) -> Cow<'a, str> { 325 | Cow::Owned(if let Some(ctx) = context { 326 | demangle_context(gettextrs::dgettext(self.0, mangle_context(ctx, string))) 327 | } else { 328 | gettextrs::dgettext(self.0, string) 329 | }) 330 | } 331 | 332 | fn ntranslate<'a>( 333 | &'a self, 334 | n: u64, 335 | singular: &'a str, 336 | plural: &'a str, 337 | context: Option<&'a str>, 338 | ) -> Cow<'a, str> { 339 | let n = n as u32; 340 | Cow::Owned(if let Some(ctx) = context { 341 | demangle_context(gettextrs::dngettext( 342 | self.0, 343 | mangle_context(ctx, singular), 344 | mangle_context(ctx, plural), 345 | n, 346 | )) 347 | } else { 348 | gettextrs::dngettext(self.0, singular, plural, n) 349 | }) 350 | } 351 | } 352 | 353 | #[cfg(not(feature = "gettext-rs"))] 354 | impl Translator for DefaultTranslator { 355 | fn translate<'a>(&'a self, string: &'a str, _context: Option<&'a str>) -> Cow<'a, str> { 356 | Cow::Borrowed(string) 357 | } 358 | 359 | fn ntranslate<'a>( 360 | &'a self, 361 | n: u64, 362 | singular: &'a str, 363 | plural: &'a str, 364 | _context: Option<&'a str>, 365 | ) -> Cow<'a, str> { 366 | Cow::Borrowed(if n == 1 { singular } else { plural }) 367 | } 368 | } 369 | 370 | #[cfg(feature = "gettext-rs")] 371 | pub fn init>>(module: &'static str, dir: T) { 372 | // FIXME: change T from `Into> to `Into` 373 | let dir = String::from_utf8(dir.into()).unwrap(); 374 | // FIXME: don't ignore errors 375 | let _ = gettextrs::bindtextdomain(domain_from_module(module), dir); 376 | 377 | static START: std::sync::Once = std::sync::Once::new(); 378 | START.call_once(|| { 379 | gettextrs::setlocale(gettextrs::LocaleCategory::LcAll, ""); 380 | }); 381 | } 382 | 383 | pub fn set_translator(module: &'static str, translator: impl Translator + 'static) { 384 | let domain = domain_from_module(module); 385 | TRANSLATORS 386 | .write() 387 | .unwrap() 388 | .insert(domain, Box::new(translator)); 389 | } 390 | 391 | pub fn unset_translator(module: &'static str) { 392 | let domain = domain_from_module(module); 393 | TRANSLATORS.write().unwrap().remove(domain); 394 | } 395 | } 396 | 397 | /// Macro used to translate a string. 398 | /// 399 | /// ``` 400 | /// # #[macro_use] extern crate tr; 401 | /// // Prints "Hello world!", or a translated version depending on the locale 402 | /// println!("{}", tr!("Hello world!")); 403 | /// ``` 404 | /// 405 | /// The string to translate need to be a string literal, as it has to be extracted by 406 | /// the `xtr` tool. One can add more argument following a subset of rust formating 407 | /// 408 | /// ``` 409 | /// # #[macro_use] extern crate tr; 410 | /// let name = "Olivier"; 411 | /// // Prints "Hello, Olivier!", or a translated version of that. 412 | /// println!("{}", tr!("Hello, {}!", name)); 413 | /// ``` 414 | /// 415 | /// Plural are using the `"singular" | "plural" % count` syntax. `{n}` will be replaced 416 | /// by the count. 417 | /// 418 | /// ``` 419 | /// # #[macro_use] extern crate tr; 420 | /// let number_of_items = 42; 421 | /// println!("{}", tr!("There is one item" | "There are {n} items" % number_of_items)); 422 | /// ``` 423 | /// 424 | /// Normal formating rules can also be used: 425 | /// 426 | /// ``` 427 | /// # #[macro_use] extern crate tr; 428 | /// let number_of_items = 42; 429 | /// let folder_name = "/tmp"; 430 | /// println!("{}", tr!("There is one item in folder {}" 431 | /// | "There are {n} items in folder {}" % number_of_items, folder_name)); 432 | /// ``` 433 | /// 434 | /// 435 | /// If the same string appears several time in the crate, it is necessary to add a 436 | /// disambiguation context, using the `"context" =>` syntax: 437 | /// 438 | /// ``` 439 | /// # #[macro_use] extern crate tr; 440 | /// // These two strings are both "Open" in english, but they may be different in a 441 | /// // foreign language. Hence, a context string is necessary. 442 | /// let action_name = tr!("File Menu" => "Open"); 443 | /// let state = tr!("Document State" => "Open"); 444 | /// ``` 445 | /// 446 | /// To enable the translation, one must first call the `tr_init!` macro once in the crate. 447 | /// To translate the strings, one can use the `xtr` utility to extract the string, 448 | /// and use the other GNU gettext tools to translate them. 449 | /// 450 | #[macro_export] 451 | macro_rules! tr { 452 | ($msgid:tt, $($tail:tt)* ) => { 453 | $crate::internal::with_translator(module_path!(), |t| $crate::runtime_format!( 454 | t.translate($msgid, None), $($tail)*)) 455 | }; 456 | ($msgid:tt) => { 457 | $crate::internal::with_translator(module_path!(), |t| $crate::runtime_format!( 458 | t.translate($msgid, None))) 459 | }; 460 | 461 | ($msgctx:tt => $msgid:tt, $($tail:tt)* ) => { 462 | $crate::internal::with_translator(module_path!(), |t| $crate::runtime_format!( 463 | t.translate($msgid, Some($msgctx)), $($tail)*)) 464 | }; 465 | ($msgctx:tt => $msgid:tt) => { 466 | $crate::internal::with_translator(module_path!(), |t| $crate::runtime_format!( 467 | t.translate($msgid, Some($msgctx)))) 468 | }; 469 | 470 | ($msgid:tt | $plur:tt % $n:expr, $($tail:tt)* ) => {{ 471 | let n = $n; 472 | $crate::internal::with_translator(module_path!(), |t| $crate::runtime_format!( 473 | t.ntranslate(n as u64, $msgid, $plur, None), $($tail)*, n=n)) 474 | }}; 475 | ($msgid:tt | $plur:tt % $n:expr) => {{ 476 | let n = $n; 477 | $crate::internal::with_translator(module_path!(), |t| $crate::runtime_format!( 478 | t.ntranslate(n as u64, $msgid, $plur, None), n)) 479 | 480 | }}; 481 | 482 | ($msgctx:tt => $msgid:tt | $plur:tt % $n:expr, $($tail:tt)* ) => {{ 483 | let n = $n; 484 | $crate::internal::with_translator(module_path!(), |t| $crate::runtime_format!( 485 | t.ntranslate(n as u64, $msgid, $plur, Some($msgctx)), $($tail)*, n=n)) 486 | }}; 487 | ($msgctx:tt => $msgid:tt | $plur:tt % $n:expr) => {{ 488 | let n = $n; 489 | $crate::internal::with_translator(module_path!(), |t| $crate::runtime_format!( 490 | t.ntranslate(n as u64, $msgid, $plur, Some($msgctx)), n)) 491 | }}; 492 | } 493 | 494 | /// Initialize the translation for a crate, using gettext's bindtextdomain 495 | /// 496 | /// The macro should be called to specify the path in which the .mo files can be looked for. 497 | /// The argument is the string passed to bindtextdomain 498 | /// 499 | /// The alternative is to call the set_translator! macro 500 | /// 501 | /// This macro is available only if the feature "gettext-rs" is enabled 502 | #[cfg(feature = "gettext-rs")] 503 | #[macro_export] 504 | macro_rules! tr_init { 505 | ($path:expr) => { 506 | $crate::internal::init(module_path!(), $path) 507 | }; 508 | } 509 | 510 | /// Set the translator to be used for this crate. 511 | /// 512 | /// The argument needs to be something implementing the [`Translator`] trait 513 | /// 514 | /// For example, using the gettext crate (if the gettext feature is enabled) 515 | /// ```ignore 516 | /// let f = File::open("french.mo").expect("could not open the catalog"); 517 | /// let catalog = Catalog::parse(f).expect("could not parse the catalog"); 518 | /// set_translator!(catalog); 519 | /// ``` 520 | #[macro_export] 521 | macro_rules! set_translator { 522 | ($translator:expr) => { 523 | $crate::internal::set_translator(module_path!(), $translator) 524 | }; 525 | } 526 | 527 | /// Clears the translator to be used for this crate. 528 | /// 529 | /// Use this macro to return back to the source language. 530 | #[macro_export] 531 | macro_rules! unset_translator { 532 | () => { 533 | $crate::internal::unset_translator(module_path!()) 534 | }; 535 | } 536 | 537 | #[cfg(feature = "gettext")] 538 | impl Translator for gettext::Catalog { 539 | fn translate<'a>(&'a self, string: &'a str, context: Option<&'a str>) -> Cow<'a, str> { 540 | Cow::Borrowed(if let Some(ctx) = context { 541 | self.pgettext(ctx, string) 542 | } else { 543 | self.gettext(string) 544 | }) 545 | } 546 | fn ntranslate<'a>( 547 | &'a self, 548 | n: u64, 549 | singular: &'a str, 550 | plural: &'a str, 551 | context: Option<&'a str>, 552 | ) -> Cow<'a, str> { 553 | Cow::Borrowed(if let Some(ctx) = context { 554 | self.npgettext(ctx, singular, plural, n) 555 | } else { 556 | self.ngettext(singular, plural, n) 557 | }) 558 | } 559 | } 560 | 561 | #[cfg(test)] 562 | mod tests { 563 | #[test] 564 | fn it_works() { 565 | assert_eq!(tr!("Hello"), "Hello"); 566 | assert_eq!(tr!("ctx" => "Hello"), "Hello"); 567 | assert_eq!(tr!("Hello {}", "world"), "Hello world"); 568 | assert_eq!(tr!("ctx" => "Hello {}", "world"), "Hello world"); 569 | 570 | assert_eq!( 571 | tr!("I have one item" | "I have {n} items" % 1), 572 | "I have one item" 573 | ); 574 | assert_eq!( 575 | tr!("ctx" => "I have one item" | "I have {n} items" % 42), 576 | "I have 42 items" 577 | ); 578 | assert_eq!( 579 | tr!("{} have one item" | "{} have {n} items" % 42, "I"), 580 | "I have 42 items" 581 | ); 582 | assert_eq!( 583 | tr!("ctx" => "{0} have one item" | "{0} have {n} items" % 42, "I"), 584 | "I have 42 items" 585 | ); 586 | 587 | assert_eq!( 588 | tr!("{} = {}", 255, format_args!("{:#x}", 255)), 589 | "255 = 0xff" 590 | ); 591 | } 592 | } 593 | -------------------------------------------------------------------------------- /tr/src/rspolib_translator.rs: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2025 SixtyFPS GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 4 | associated documentation files (the "Software"), to deal in the Software without restriction, 5 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, 7 | subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial 10 | portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 13 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 14 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 15 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | */ 18 | 19 | use std::collections::HashMap; 20 | 21 | /// Use this type to load `.po` files directly in your application for translations. 22 | /// 23 | /// Construct the `PoTranslator` from either a path via [`Self::from_path`] or a vec of 24 | /// data via [`Self::from_vec_u8`]. 25 | /// 26 | /// `PoTranslator` implements the [`crate::Translator`] trait and can be passed to 27 | /// [`crate::set_translator!`]. 28 | #[cfg(feature = "po-translator")] 29 | pub struct PoTranslator(RSPoLibTranslator); 30 | 31 | #[cfg(feature = "po-translator")] 32 | impl PoTranslator { 33 | /// Constructs a `PoTranslator` from the given path. 34 | pub fn from_path(path: &std::path::Path) -> Result { 35 | let options = rspolib::FileOptions::from(path); 36 | Ok(Self( 37 | rspolib::pofile(options) 38 | .map_err(|parse_error| MoPoTranslatorLoadError::PoParseError(parse_error.into())) 39 | .and_then(RSPoLibTranslator::try_from)?, 40 | )) 41 | } 42 | 43 | /// Constructs a `PoTranslator` from the given raw vec u8 that must be valid `.po` file contents. 44 | pub fn from_vec_u8(data: Vec) -> Result { 45 | let options = rspolib::FileOptions::from(data); 46 | Ok(Self( 47 | rspolib::pofile(options) 48 | .map_err(|parse_error| MoPoTranslatorLoadError::PoParseError(parse_error.into())) 49 | .and_then(RSPoLibTranslator::try_from)?, 50 | )) 51 | } 52 | } 53 | 54 | #[cfg(feature = "po-translator")] 55 | impl crate::Translator for PoTranslator { 56 | fn translate<'a>( 57 | &'a self, 58 | string: &'a str, 59 | context: Option<&'a str>, 60 | ) -> std::borrow::Cow<'a, str> { 61 | self.0.translate(string, context) 62 | } 63 | 64 | fn ntranslate<'a>( 65 | &'a self, 66 | n: u64, 67 | singular: &'a str, 68 | plural: &'a str, 69 | context: Option<&'a str>, 70 | ) -> std::borrow::Cow<'a, str> { 71 | self.0.ntranslate(n, singular, plural, context) 72 | } 73 | } 74 | 75 | /// Use this type to load `.mo` files directly in your application for translations. 76 | /// 77 | /// Construct the `MoTranslator` from either a path via [`Self::from_path`] or a vec of 78 | /// data via [`Self::from_vec_u8`]. 79 | /// 80 | /// `MoTranslator` implements the [`crate::Translator`] trait and can be passed to 81 | /// [`crate::set_translator!`]. 82 | #[cfg(feature = "mo-translator")] 83 | pub struct MoTranslator(RSPoLibTranslator); 84 | 85 | #[cfg(feature = "mo-translator")] 86 | impl MoTranslator { 87 | /// Constructs a `MoTranslator` from the given path. 88 | pub fn from_path(path: &std::path::Path) -> Result { 89 | let options = rspolib::FileOptions::from(path); 90 | Ok(Self( 91 | rspolib::mofile(options) 92 | .map_err(|parse_error| MoPoTranslatorLoadError::MoParseError(parse_error.into())) 93 | .and_then(RSPoLibTranslator::try_from)?, 94 | )) 95 | } 96 | 97 | /// Constructs a `MoTranslator` from the given raw vec u8 that must be valid `.mo` file contents. 98 | pub fn from_vec_u8(data: Vec) -> Result { 99 | let options = rspolib::FileOptions::from(data); 100 | Ok(Self( 101 | rspolib::mofile(options) 102 | .map_err(|parse_error| MoPoTranslatorLoadError::MoParseError(parse_error.into())) 103 | .and_then(RSPoLibTranslator::try_from)?, 104 | )) 105 | } 106 | } 107 | 108 | #[cfg(feature = "mo-translator")] 109 | impl crate::Translator for MoTranslator { 110 | fn translate<'a>( 111 | &'a self, 112 | string: &'a str, 113 | context: Option<&'a str>, 114 | ) -> std::borrow::Cow<'a, str> { 115 | self.0.translate(string, context) 116 | } 117 | 118 | fn ntranslate<'a>( 119 | &'a self, 120 | n: u64, 121 | singular: &'a str, 122 | plural: &'a str, 123 | context: Option<&'a str>, 124 | ) -> std::borrow::Cow<'a, str> { 125 | self.0.ntranslate(n, singular, plural, context) 126 | } 127 | } 128 | 129 | /// Use the `RSPoLibTranslator` to load messages from a `.po` or `.mo` files. 130 | /// 131 | /// Convert your [`rspolib::POFile`] or [`rspolib::MOFile`] into this type 132 | /// using [`RSPoLibTranslator::try_from`]. 133 | /// 134 | /// `RSPoLibTranslator` implements the [`crate::Translator`] trait and can then 135 | /// be passed to [`crate::set_translator!`]. 136 | struct RSPoLibTranslator { 137 | /// Translations are indexed by message id, optional, plural message id, and optional context. 138 | translations: HashMap, 139 | plural_rules: plural_rule_parser::Expression, 140 | } 141 | 142 | impl RSPoLibTranslator { 143 | fn new( 144 | entries: impl IntoIterator, 145 | metadata: &HashMap, 146 | ) -> Result { 147 | let translations = entries 148 | .into_iter() 149 | .filter_map(|entry| { 150 | let translation = if entry.msgid_plural.is_some() { 151 | Some(Translation::Plural(entry.msgstr_plural.into_boxed_slice())) 152 | } else { 153 | entry.msgstr.map(|msgstr| Translation::Singular(msgstr)) 154 | }; 155 | 156 | let key = TranslationKey { 157 | message_id: entry.msgid, 158 | plural_message_id: entry.msgid_plural, 159 | context: entry.msgctxt, 160 | }; 161 | 162 | translation.map(|t| (key, t)) 163 | }) 164 | .collect(); 165 | 166 | let plural_rules = metadata 167 | .get("Plural-Forms") 168 | .and_then(|entry| { 169 | entry.split(';').find_map(|sub_entry| { 170 | let (key, expression) = sub_entry.split_once('=')?; 171 | if key == "plural" { 172 | Some( 173 | plural_rule_parser::parse_rule_expression(expression).map_err( 174 | |parse_error| MoPoTranslatorLoadError::InvalidPluralRules { 175 | rules: expression.to_string(), 176 | error: parse_error.0.to_string(), 177 | }, 178 | ), 179 | ) 180 | } else { 181 | None 182 | } 183 | }) 184 | }) 185 | .unwrap_or_else(|| Ok(plural_rule_parser::parse_rule_expression("n != 1").unwrap()))?; 186 | 187 | Ok(RSPoLibTranslator { 188 | translations, 189 | plural_rules, 190 | }) 191 | } 192 | } 193 | 194 | impl TryFrom for RSPoLibTranslator { 195 | type Error = MoPoTranslatorLoadError; 196 | fn try_from(mofile: rspolib::MOFile) -> Result { 197 | RSPoLibTranslator::new( 198 | mofile.entries.into_iter().map( 199 | |rspolib::MOEntry { 200 | msgid, 201 | msgstr, 202 | msgstr_plural, 203 | msgid_plural, 204 | msgctxt, 205 | }| { 206 | POMOEntry { 207 | msgid, 208 | msgstr, 209 | msgid_plural, 210 | msgstr_plural, 211 | msgctxt, 212 | } 213 | }, 214 | ), 215 | &mofile.metadata, 216 | ) 217 | } 218 | } 219 | 220 | impl TryFrom for RSPoLibTranslator { 221 | type Error = MoPoTranslatorLoadError; 222 | fn try_from(mofile: rspolib::POFile) -> Result { 223 | RSPoLibTranslator::new( 224 | mofile.entries.into_iter().map( 225 | |rspolib::POEntry { 226 | msgid, 227 | msgstr, 228 | msgstr_plural, 229 | msgid_plural, 230 | msgctxt, 231 | .. 232 | }| { 233 | POMOEntry { 234 | msgid, 235 | msgstr, 236 | msgid_plural, 237 | msgstr_plural, 238 | msgctxt, 239 | } 240 | }, 241 | ), 242 | &mofile.metadata, 243 | ) 244 | } 245 | } 246 | 247 | #[derive(PartialEq, Eq, Hash)] 248 | struct TranslationKey { 249 | message_id: String, 250 | plural_message_id: Option, 251 | context: Option, 252 | } 253 | 254 | enum Translation { 255 | Singular(String), 256 | Plural(Box<[String]>), 257 | } 258 | 259 | struct POMOEntry { 260 | msgid: String, 261 | msgstr: Option, 262 | msgid_plural: Option, 263 | msgstr_plural: Vec, 264 | msgctxt: Option, 265 | } 266 | 267 | /// This error type is returned when creating a [`PoTranslator`] or [`MoTranslator`] 268 | /// and an error occurding during parsing. 269 | #[non_exhaustive] 270 | pub enum MoPoTranslatorLoadError { 271 | /// This variant describes a failure during parsing of the `.po` file. 272 | PoParseError(Box), 273 | /// This variant describes a failure during parsing of the `.mo` file. 274 | MoParseError(Box), 275 | /// This variant describes a failure during parsing of the plural rules. 276 | InvalidPluralRules { 277 | /// A copy of the plural rules that could not be parsed. 278 | rules: String, 279 | /// The error that occured during parsing of the plural rules. 280 | error: String, 281 | }, 282 | } 283 | 284 | impl std::error::Error for MoPoTranslatorLoadError {} 285 | 286 | impl core::fmt::Display for MoPoTranslatorLoadError { 287 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 288 | match self { 289 | Self::PoParseError(error) => { 290 | write!(f, "Error parsing `po` file: {}", error) 291 | } 292 | Self::MoParseError(error) => { 293 | write!(f, "Error parsing `mo` file: {}", error) 294 | } 295 | Self::InvalidPluralRules { rules, error } => { 296 | write!(f, "Error parsing plural rules '{}': {}", rules, error) 297 | } 298 | } 299 | } 300 | } 301 | 302 | impl core::fmt::Debug for MoPoTranslatorLoadError { 303 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 304 | core::fmt::Display::fmt(self, f) 305 | } 306 | } 307 | 308 | impl crate::Translator for RSPoLibTranslator { 309 | fn translate<'a>( 310 | &'a self, 311 | message_id: &'a str, 312 | context: Option<&'a str>, 313 | ) -> std::borrow::Cow<'a, str> { 314 | std::borrow::Cow::Borrowed( 315 | self.translations 316 | .get(&(message_id, None, context) as &dyn TranslationLookup) 317 | .and_then(|translation| match translation { 318 | Translation::Singular(message) => Some(message.as_str()), 319 | Translation::Plural(_) => None, 320 | }) 321 | .unwrap_or(message_id), 322 | ) 323 | } 324 | 325 | fn ntranslate<'a>( 326 | &'a self, 327 | n: u64, 328 | singular: &'a str, 329 | plural: &'a str, 330 | context: Option<&'a str>, 331 | ) -> std::borrow::Cow<'a, str> { 332 | std::borrow::Cow::Borrowed( 333 | self.translations 334 | .get(&(singular, Some(plural), context) as &dyn TranslationLookup) 335 | .and_then(|translation| match translation { 336 | Translation::Singular(_) => None, 337 | Translation::Plural(items) => { 338 | let translation_pick = self.plural_rules.evaluate(n); 339 | items.get(translation_pick as usize).map(|s| s.as_str()) 340 | } 341 | }) 342 | .unwrap_or_else(|| if n == 1 { singular } else { plural }), 343 | ) 344 | } 345 | } 346 | 347 | /// Helper trait to permit lookup of translations without copying the key, 348 | /// by using a dyn trait object as the borrowed type for the (String, Option) 349 | /// key tuple. 350 | trait TranslationLookup { 351 | fn message_id(&self) -> &str; 352 | fn plural_message_id(&self) -> Option<&str>; 353 | fn context(&self) -> Option<&str>; 354 | } 355 | 356 | impl TranslationLookup for TranslationKey { 357 | fn message_id(&self) -> &str { 358 | &self.message_id 359 | } 360 | 361 | fn plural_message_id(&self) -> Option<&str> { 362 | self.plural_message_id.as_deref() 363 | } 364 | 365 | fn context(&self) -> Option<&str> { 366 | self.context.as_deref() 367 | } 368 | } 369 | 370 | impl<'a> TranslationLookup for (&'a str, Option<&'a str>, Option<&'a str>) { 371 | fn message_id(&self) -> &str { 372 | self.0 373 | } 374 | 375 | fn plural_message_id(&self) -> Option<&str> { 376 | self.1 377 | } 378 | 379 | fn context(&self) -> Option<&str> { 380 | self.2 381 | } 382 | } 383 | 384 | impl std::hash::Hash for dyn TranslationLookup + '_ { 385 | fn hash(&self, state: &mut H) { 386 | self.message_id().hash(state); 387 | self.plural_message_id().hash(state); 388 | self.context().hash(state); 389 | } 390 | } 391 | 392 | impl std::cmp::PartialEq for dyn TranslationLookup + '_ { 393 | fn eq(&self, other: &Self) -> bool { 394 | self.message_id() == other.message_id() 395 | && self.plural_message_id() == other.plural_message_id() 396 | && self.context() == other.context() 397 | } 398 | } 399 | 400 | impl std::cmp::Eq for dyn TranslationLookup + '_ {} 401 | 402 | impl<'a> std::borrow::Borrow for TranslationKey { 403 | fn borrow(&self) -> &(dyn TranslationLookup + 'a) { 404 | self 405 | } 406 | } 407 | 408 | mod plural_rule_parser { 409 | pub enum BinaryOp { 410 | And, 411 | Or, 412 | Modulo, 413 | Equal, 414 | NotEqual, 415 | Greater, 416 | Smaller, 417 | GreaterOrEqual, 418 | SmallerOrEqual, 419 | } 420 | 421 | pub enum SubExpression { 422 | NumberLiteral(u64), 423 | NVariable, 424 | Condition { 425 | condition: u16, 426 | true_expr: u16, 427 | false_expr: u16, 428 | }, 429 | BinaryOp { 430 | op: BinaryOp, 431 | lhs: u16, 432 | rhs: u16, 433 | }, 434 | } 435 | 436 | impl SubExpression { 437 | fn evaluate(&self, sub_expressions: &[SubExpression], n: u64) -> u64 { 438 | match self { 439 | Self::NumberLiteral(value) => *value, 440 | Self::NVariable => n, 441 | Self::Condition { 442 | condition, 443 | true_expr, 444 | false_expr, 445 | } => { 446 | if sub_expressions[*condition as usize].evaluate(sub_expressions, n) != 0 { 447 | sub_expressions[*true_expr as usize].evaluate(sub_expressions, n) 448 | } else { 449 | sub_expressions[*false_expr as usize].evaluate(sub_expressions, n) 450 | } 451 | } 452 | Self::BinaryOp { op, lhs, rhs } => { 453 | let lhs_value = sub_expressions[*lhs as usize].evaluate(sub_expressions, n); 454 | let rhs_value = sub_expressions[*rhs as usize].evaluate(sub_expressions, n); 455 | match op { 456 | BinaryOp::And => (lhs_value != 0 && rhs_value != 0) as u64, 457 | BinaryOp::Or => (lhs_value != 0 || rhs_value != 0) as u64, 458 | BinaryOp::Modulo => lhs_value % rhs_value, 459 | BinaryOp::Equal => (lhs_value == rhs_value) as u64, 460 | BinaryOp::NotEqual => (lhs_value != rhs_value) as u64, 461 | BinaryOp::Greater => (lhs_value > rhs_value) as u64, 462 | BinaryOp::Smaller => (lhs_value < rhs_value) as u64, 463 | BinaryOp::GreaterOrEqual => (lhs_value >= rhs_value) as u64, 464 | BinaryOp::SmallerOrEqual => (lhs_value <= rhs_value) as u64, 465 | } 466 | } 467 | } 468 | } 469 | } 470 | 471 | #[cfg(test)] 472 | struct DisplayExpression<'a>(usize, &'a [SubExpression]); 473 | 474 | #[cfg(test)] 475 | impl<'a> DisplayExpression<'a> { 476 | fn sub(&self, index: u16) -> Self { 477 | Self(index as usize, self.1) 478 | } 479 | } 480 | 481 | #[cfg(test)] 482 | impl<'a> std::fmt::Display for DisplayExpression<'a> { 483 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 484 | match &self.1[self.0] { 485 | SubExpression::NumberLiteral(value) => write!(f, "{}", value), 486 | SubExpression::NVariable => write!(f, "n"), 487 | SubExpression::Condition { 488 | condition, 489 | true_expr, 490 | false_expr, 491 | } => { 492 | write!( 493 | f, 494 | "({} ? {} : {})", 495 | self.sub(*condition), 496 | self.sub(*true_expr), 497 | self.sub(*false_expr) 498 | ) 499 | } 500 | SubExpression::BinaryOp { op, lhs, rhs } => { 501 | let op_str = match op { 502 | BinaryOp::And => "&", 503 | BinaryOp::Or => "|", 504 | BinaryOp::Modulo => "%", 505 | BinaryOp::Equal => "=", 506 | BinaryOp::NotEqual => "!=", 507 | BinaryOp::Greater => ">", 508 | BinaryOp::Smaller => "<", 509 | BinaryOp::GreaterOrEqual => "≥", 510 | BinaryOp::SmallerOrEqual => "≤", 511 | }; 512 | write!(f, "({} {} {})", self.sub(*lhs), op_str, self.sub(*rhs)) 513 | } 514 | } 515 | } 516 | } 517 | 518 | #[derive(Default)] 519 | struct ExpressionBuilder(Vec); 520 | impl ExpressionBuilder { 521 | fn add(&mut self, sub_expr: SubExpression) -> u16 { 522 | let index = self.0.len(); 523 | self.0.push(sub_expr); 524 | index as u16 525 | } 526 | } 527 | 528 | pub struct Expression { 529 | sub_expressions: Box<[SubExpression]>, 530 | } 531 | 532 | impl From for Expression { 533 | fn from(expression_builder: ExpressionBuilder) -> Self { 534 | Self { 535 | sub_expressions: expression_builder.0.into_boxed_slice(), 536 | } 537 | } 538 | } 539 | 540 | impl Expression { 541 | pub fn evaluate(&self, n: u64) -> usize { 542 | self.sub_expressions 543 | .last() 544 | .map(|expr| expr.evaluate(&self.sub_expressions, n) as usize) 545 | .unwrap_or(0) 546 | } 547 | } 548 | 549 | pub struct ParseError<'a>(pub &'static str, &'a [u8]); 550 | impl std::fmt::Debug for ParseError<'_> { 551 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 552 | write!( 553 | f, 554 | "ParseError({}, rest={:?})", 555 | self.0, 556 | std::str::from_utf8(self.1).unwrap() 557 | ) 558 | } 559 | } 560 | pub fn parse_rule_expression(string: &str) -> Result> { 561 | let ascii = string.as_bytes(); 562 | let mut expression_builder = ExpressionBuilder::default(); 563 | let s = parse_expression(ascii, &mut expression_builder)?; 564 | if !s.rest.is_empty() { 565 | return Err(ParseError("extra character in string", s.rest)); 566 | } 567 | if matches!(s.ty, Ty::Boolean) { 568 | let true_expr = expression_builder.add(SubExpression::NumberLiteral(1)); 569 | let false_expr = expression_builder.add(SubExpression::NumberLiteral(0)); 570 | expression_builder.add(SubExpression::Condition { 571 | condition: s.expr, 572 | true_expr, 573 | false_expr, 574 | }); 575 | } 576 | Ok(expression_builder.into()) 577 | } 578 | 579 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 580 | enum Ty { 581 | Number, 582 | Boolean, 583 | } 584 | 585 | struct ParsingState<'a> { 586 | expr: u16, 587 | rest: &'a [u8], 588 | ty: Ty, 589 | } 590 | 591 | impl ParsingState<'_> { 592 | fn skip_whitespace(self) -> Self { 593 | let rest = skip_whitespace(self.rest); 594 | Self { rest, ..self } 595 | } 596 | } 597 | 598 | /// ` ('?' : )?` 599 | fn parse_expression<'a>( 600 | string: &'a [u8], 601 | builder: &mut ExpressionBuilder, 602 | ) -> Result, ParseError<'a>> { 603 | let string = skip_whitespace(string); 604 | let state = parse_condition(string, builder)?.skip_whitespace(); 605 | if state.ty != Ty::Boolean { 606 | return Ok(state); 607 | } 608 | if let Some(rest) = state.rest.strip_prefix(b"?") { 609 | let s1 = parse_expression(rest, builder)?.skip_whitespace(); 610 | let rest = s1 611 | .rest 612 | .strip_prefix(b":") 613 | .ok_or(ParseError("expected ':'", s1.rest))?; 614 | let s2 = parse_expression(rest, builder)?; 615 | if s1.ty != s2.ty { 616 | return Err(ParseError( 617 | "incompatible types in ternary operator", 618 | s2.rest, 619 | )); 620 | } 621 | Ok(ParsingState { 622 | expr: builder.add(SubExpression::Condition { 623 | condition: state.expr, 624 | true_expr: s1.expr, 625 | false_expr: s2.expr, 626 | }), 627 | rest: skip_whitespace(s2.rest), 628 | ty: s2.ty, 629 | }) 630 | } else { 631 | Ok(state) 632 | } 633 | } 634 | 635 | /// ` ("||" )?` 636 | fn parse_condition<'a>( 637 | string: &'a [u8], 638 | builder: &mut ExpressionBuilder, 639 | ) -> Result, ParseError<'a>> { 640 | let string = skip_whitespace(string); 641 | let state = parse_and_expr(string, builder)?.skip_whitespace(); 642 | if state.rest.is_empty() { 643 | return Ok(state); 644 | } 645 | if let Some(rest) = state.rest.strip_prefix(b"||") { 646 | let state2 = parse_condition(rest, builder)?; 647 | if state.ty != Ty::Boolean || state2.ty != Ty::Boolean { 648 | return Err(ParseError("incompatible types in || operator", state2.rest)); 649 | } 650 | Ok(ParsingState { 651 | expr: builder.add(SubExpression::BinaryOp { 652 | lhs: state.expr, 653 | rhs: state2.expr, 654 | op: BinaryOp::Or, 655 | }), 656 | ty: Ty::Boolean, 657 | rest: skip_whitespace(state2.rest), 658 | }) 659 | } else { 660 | Ok(state) 661 | } 662 | } 663 | 664 | /// ` ("&&" )?` 665 | fn parse_and_expr<'a>( 666 | string: &'a [u8], 667 | builder: &mut ExpressionBuilder, 668 | ) -> Result, ParseError<'a>> { 669 | let string = skip_whitespace(string); 670 | let state = parse_cmp_expr(string, builder)?.skip_whitespace(); 671 | if state.rest.is_empty() { 672 | return Ok(state); 673 | } 674 | if let Some(rest) = state.rest.strip_prefix(b"&&") { 675 | let state2 = parse_and_expr(rest, builder)?; 676 | if state.ty != Ty::Boolean || state2.ty != Ty::Boolean { 677 | return Err(ParseError("incompatible types in || operator", state2.rest)); 678 | } 679 | Ok(ParsingState { 680 | expr: builder.add(SubExpression::BinaryOp { 681 | lhs: state.expr, 682 | rhs: state2.expr, 683 | op: BinaryOp::And, 684 | }), 685 | ty: Ty::Boolean, 686 | rest: skip_whitespace(state2.rest), 687 | }) 688 | } else { 689 | Ok(state) 690 | } 691 | } 692 | 693 | /// ` ('=='|'!='|'<'|'>'|'<='|'>=' )?` 694 | fn parse_cmp_expr<'a>( 695 | string: &'a [u8], 696 | builder: &mut ExpressionBuilder, 697 | ) -> Result, ParseError<'a>> { 698 | let string = skip_whitespace(string); 699 | let mut state = parse_value(string, builder)?; 700 | state.rest = skip_whitespace(state.rest); 701 | if state.rest.is_empty() { 702 | return Ok(state); 703 | } 704 | 705 | for (token, op) in [ 706 | (b"==" as &[u8], BinaryOp::Equal), 707 | (b"!=", BinaryOp::NotEqual), 708 | (b"<=", BinaryOp::SmallerOrEqual), 709 | (b">=", BinaryOp::GreaterOrEqual), 710 | (b"<", BinaryOp::Smaller), 711 | (b">", BinaryOp::Greater), 712 | ] { 713 | if let Some(rest) = state.rest.strip_prefix(token) { 714 | let state2 = parse_cmp_expr(rest, builder)?; 715 | if state.ty != Ty::Number || state2.ty != Ty::Number { 716 | return Err(ParseError("incompatible types in comparison", state2.rest)); 717 | } 718 | return Ok(ParsingState { 719 | expr: builder.add(SubExpression::BinaryOp { 720 | lhs: state.expr, 721 | rhs: state2.expr, 722 | op, 723 | }), 724 | ty: Ty::Boolean, 725 | rest: skip_whitespace(state2.rest), 726 | }); 727 | } 728 | } 729 | Ok(state) 730 | } 731 | 732 | /// ` ('%' )?` 733 | fn parse_value<'a>( 734 | string: &'a [u8], 735 | builder: &mut ExpressionBuilder, 736 | ) -> Result, ParseError<'a>> { 737 | let string = skip_whitespace(string); 738 | let mut state = parse_term(string, builder)?; 739 | state.rest = skip_whitespace(state.rest); 740 | if state.rest.is_empty() { 741 | return Ok(state); 742 | } 743 | if let Some(rest) = state.rest.strip_prefix(b"%") { 744 | let state2 = parse_term(rest, builder)?; 745 | if state.ty != Ty::Number || state2.ty != Ty::Number { 746 | return Err(ParseError("incompatible types in % operator", state2.rest)); 747 | } 748 | Ok(ParsingState { 749 | expr: builder.add(SubExpression::BinaryOp { 750 | lhs: state.expr, 751 | rhs: state2.expr, 752 | op: BinaryOp::Modulo, 753 | }), 754 | ty: Ty::Number, 755 | rest: skip_whitespace(state2.rest), 756 | }) 757 | } else { 758 | Ok(state) 759 | } 760 | } 761 | 762 | fn parse_term<'a>( 763 | string: &'a [u8], 764 | builder: &mut ExpressionBuilder, 765 | ) -> Result, ParseError<'a>> { 766 | let string = skip_whitespace(string); 767 | let state = match string 768 | .first() 769 | .ok_or(ParseError("unexpected end of string", string))? 770 | { 771 | b'n' => ParsingState { 772 | expr: builder.add(SubExpression::NVariable), 773 | rest: &string[1..], 774 | ty: Ty::Number, 775 | }, 776 | b'(' => { 777 | let mut s = parse_expression(&string[1..], builder)?; 778 | s.rest = s 779 | .rest 780 | .strip_prefix(b")") 781 | .ok_or(ParseError("expected ')'", s.rest))?; 782 | s 783 | } 784 | x if x.is_ascii_digit() => { 785 | let (n, rest) = parse_number(string)?; 786 | ParsingState { 787 | expr: builder.add(SubExpression::NumberLiteral(n as _)), 788 | rest, 789 | ty: Ty::Number, 790 | } 791 | } 792 | _ => return Err(ParseError("unexpected token", string)), 793 | }; 794 | Ok(state) 795 | } 796 | fn parse_number(string: &[u8]) -> Result<(i32, &[u8]), ParseError<'_>> { 797 | let end = string 798 | .iter() 799 | .position(|&c| !c.is_ascii_digit()) 800 | .unwrap_or(string.len()); 801 | let n = std::str::from_utf8(&string[..end]) 802 | .expect("string is valid utf-8") 803 | .parse() 804 | .map_err(|_| ParseError("can't parse number", string))?; 805 | Ok((n, &string[end..])) 806 | } 807 | fn skip_whitespace(mut string: &[u8]) -> &[u8] { 808 | // slice::trim_ascii_start when MSRV >= 1.80 809 | while !string.is_empty() && string[0].is_ascii_whitespace() { 810 | string = &string[1..]; 811 | } 812 | string 813 | } 814 | 815 | #[test] 816 | fn test_parse_rule_expression() { 817 | #[track_caller] 818 | fn p(string: &str) -> String { 819 | let expr = parse_rule_expression(string).expect("parse error"); 820 | DisplayExpression( 821 | expr.sub_expressions 822 | .len() 823 | .checked_sub(1) 824 | .expect("no expression found"), 825 | &expr.sub_expressions, 826 | ) 827 | .to_string() 828 | } 829 | 830 | // en 831 | assert_eq!(p("n != 1"), "((n != 1) ? 1 : 0)"); 832 | // fr 833 | assert_eq!(p("n > 1"), "((n > 1) ? 1 : 0)"); 834 | // ar 835 | assert_eq!( 836 | p("(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5)"), 837 | "((n = 0) ? 0 : ((n = 1) ? 1 : ((n = 2) ? 2 : ((((n % 100) ≥ 3) & ((n % 100) ≤ 10)) ? 3 : (((n % 100) ≥ 11) ? 4 : 5)))))" 838 | ); 839 | // ga 840 | assert_eq!(p("n==1 ? 0 : n==2 ? 1 : (n>2 && n<7) ? 2 :(n>6 && n<11) ? 3 : 4"), "((n = 1) ? 0 : ((n = 2) ? 1 : (((n > 2) & (n < 7)) ? 2 : (((n > 6) & (n < 11)) ? 3 : 4))))"); 841 | // ja 842 | assert_eq!(p("0"), "0"); 843 | // pl 844 | assert_eq!( 845 | p("(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)"), 846 | "((n = 1) ? 0 : ((((n % 10) ≥ 2) & (((n % 10) ≤ 4) & (((n % 100) < 10) | ((n % 100) ≥ 20)))) ? 1 : 2))", 847 | ); 848 | 849 | // ru 850 | assert_eq!( 851 | p("(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)"), 852 | "((((n % 10) = 1) & ((n % 100) != 11)) ? 0 : ((((n % 10) ≥ 2) & (((n % 10) ≤ 4) & (((n % 100) < 10) | ((n % 100) ≥ 20)))) ? 1 : 2))", 853 | ); 854 | } 855 | } 856 | 857 | #[test] 858 | fn single_message() { 859 | use crate::Translator; 860 | 861 | let mut synthetic_mofile = rspolib::MOFile::new(rspolib::FileOptions::default()); 862 | synthetic_mofile.entries.push(rspolib::MOEntry { 863 | msgid: "Big Error".to_string(), 864 | msgstr: Some("Großer Fehler".to_string()), 865 | ..Default::default() 866 | }); 867 | synthetic_mofile.entries.push(rspolib::MOEntry { 868 | msgid: "Small Error".to_string(), 869 | msgstr: Some("Kleiner Fehler".to_string()), 870 | ..Default::default() 871 | }); 872 | synthetic_mofile.entries.push(rspolib::MOEntry { 873 | msgid: "Small Error".to_string(), 874 | msgstr: Some("Kleiner Fehler im Kontext".to_string()), 875 | msgctxt: Some("some context".to_string()), 876 | ..Default::default() 877 | }); 878 | 879 | let translator = RSPoLibTranslator::try_from(synthetic_mofile).unwrap(); 880 | assert_eq!(translator.translate("Big Error", None), "Großer Fehler"); 881 | assert_eq!(translator.translate("Small Error", None), "Kleiner Fehler"); 882 | assert_eq!( 883 | translator.translate("Small Error", Some("some context")), 884 | "Kleiner Fehler im Kontext" 885 | ); 886 | } 887 | 888 | #[test] 889 | fn plural_message() { 890 | use crate::Translator; 891 | 892 | let mut synthetic_mofile = rspolib::MOFile::new(rspolib::FileOptions::default()); 893 | synthetic_mofile.entries.push(rspolib::MOEntry { 894 | msgid: "{n} file".to_string(), 895 | msgid_plural: Some("{n} files".to_string()), 896 | msgstr: None, 897 | msgstr_plural: vec!["{n} Datei".to_string(), "{n} Dateien".to_string()], 898 | ..Default::default() 899 | }); 900 | synthetic_mofile.metadata.insert( 901 | "Plural-Forms".to_string(), 902 | "nplurals=2; plural=(n != 1)".to_string(), 903 | ); 904 | 905 | let translator = RSPoLibTranslator::try_from(synthetic_mofile).unwrap(); 906 | assert_eq!( 907 | translator.ntranslate(1, "{n} file", "{n} files", None), 908 | "{n} Datei" 909 | ); 910 | assert_eq!( 911 | translator.ntranslate(0, "{n} file", "{n} files", None), 912 | "{n} Dateien" 913 | ); 914 | assert_eq!( 915 | translator.ntranslate(3, "{n} file", "{n} files", None), 916 | "{n} Dateien" 917 | ); 918 | } 919 | --------------------------------------------------------------------------------