├── .codespellrc ├── .editorconfig ├── .github └── workflows │ ├── book.yaml │ └── ci.yaml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── book ├── book.toml └── src │ ├── SUMMARY.md │ ├── getting_started.md │ ├── optional_features.md │ ├── overview.md │ ├── reference │ ├── build_options.md │ └── json_format.md │ └── tips │ ├── extensions.md │ └── i18n-ally.png ├── rosetta-build ├── Cargo.toml ├── README.md └── src │ ├── builder.rs │ ├── error.rs │ ├── gen.rs │ ├── lib.rs │ └── parser.rs ├── rosetta-i18n ├── Cargo.toml ├── README.md └── src │ ├── lib.rs │ ├── provider.rs │ └── serde_helpers.rs └── rosetta-test ├── Cargo.toml ├── build.rs ├── locales ├── en.json └── fr.json └── src └── lib.rs /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | skip = target,.git,rosetta-test 3 | ignore-words-list = crate,ser 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.yaml] 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/workflows/book.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy documentation 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | steps: 12 | - name: Checkout sources 13 | uses: actions/checkout@v2 14 | 15 | - name: Setup mdBook 16 | uses: peaceiris/actions-mdbook@v1 17 | with: 18 | mdbook-version: 'latest' 19 | 20 | - name: Build book 21 | run: mdbook build ./book 22 | 23 | - name: Deploy to GitHub Pages 24 | uses: peaceiris/actions-gh-pages@v3 25 | with: 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | publish_dir: ./book/output 28 | force_orphan: true 29 | user_name: 'github-actions[bot]' 30 | user_email: 'github-actions[bot]@users.noreply.github.com' 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout sources 12 | uses: actions/checkout@v2 13 | 14 | - name: Cache dependencies 15 | uses: Swatinem/rust-cache@v1 16 | 17 | - name: Run cargo test 18 | run: cargo test --all-features 19 | 20 | clippy: 21 | name: Clippy 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout sources 26 | uses: actions/checkout@v2 27 | 28 | - name: Cache dependencies 29 | uses: Swatinem/rust-cache@v1 30 | 31 | - name: Run clippy 32 | uses: actions-rs/clippy-check@v1 33 | with: 34 | token: ${{ secrets.GITHUB_TOKEN }} 35 | args: --all-features --tests 36 | 37 | rustfmt: 38 | name: Format 39 | runs-on: ubuntu-latest 40 | 41 | steps: 42 | - name: Checkout sources 43 | uses: actions/checkout@v2 44 | 45 | - name: Run cargo fmt 46 | run: cargo fmt --all -- --check 47 | 48 | build-docs: 49 | name: Build docs 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - name: Checkout sources 54 | uses: actions/checkout@v2 55 | 56 | - name: Cache dependencies 57 | uses: Swatinem/rust-cache@v1 58 | 59 | - name: Build docs 60 | env: 61 | RUSTDOCFLAGS: -D rustdoc::broken_intra_doc_links 62 | run: cargo doc --no-deps --all-features 63 | 64 | codespell: 65 | name: Spelling 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: Checkout sources 69 | uses: actions/checkout@v2 70 | 71 | - name: Run Codespell 72 | uses: codespell-project/actions-codespell@master 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /.vscode 3 | /book/output 4 | 5 | Cargo.lock 6 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "rosetta-i18n", 4 | "rosetta-build", 5 | "rosetta-test" 6 | ] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rosetta-i18n 2 | [![Crates.io](https://img.shields.io/crates/v/rosetta-i18n)](https://crates.io/crates/rosetta-i18n) 3 | [![dependency status](https://deps.rs/repo/github/baptiste0928/rosetta/status.svg)](https://deps.rs/repo/github/baptiste0928/rosetta) 4 | [![docs.rs](https://img.shields.io/docsrs/rosetta-i18n)](https://docs.rs/rosetta-i18n/) 5 | [![CI](https://github.com/baptiste0928/rosetta/actions/workflows/ci.yaml/badge.svg?event=push)](https://github.com/baptiste0928/rosetta/actions/workflows/ci.yaml) 6 | 7 | **rosetta-i18n** is an easy-to-use and opinionated Rust internationalization (i18n) library powered by code generation. 8 | 9 | ```rust 10 | rosetta_i18n::include_translations!(); 11 | 12 | println!(Lang::En.hello("world")); // Hello, world! 13 | ``` 14 | 15 | **[Documentation](https://baptiste0928.github.io/rosetta/)** 16 | 17 | ## Features 18 | - **No runtime errors.** Translation files are parsed at build time, so your code will never fail due to translations anymore. 19 | - **No dependencies.** This crate aims to have the smallest runtime overheat compared to raw strings. There is no additional dependencies at runtime. 20 | - **Standard JSON format.** Translations are written in JSON file with a syntax used by many other i18n libraries. Therefore, most translation services support it out of the box. 21 | - **String formatting** is supported. 22 | 23 | ## Installation 24 | Rosetta is separated into two crates, `rosetta-i18n` and `rosetta-build`. To install both, add the following to your `Cargo.toml`: 25 | 26 | ```toml 27 | [dependencies] 28 | rosetta-i18n = "0.1" 29 | 30 | [build-dependencies] 31 | rosetta-build = "0.1" 32 | ``` 33 | 34 | ## Documentation 35 | 36 | The documentation is available on https://baptiste0928.github.io/rosetta/. 37 | 38 | You can also read the API documentation on *docs.rs*: [`rosetta-i18n`](https://docs.rs/rosetta-i18n/) 39 | and [`rosetta-build`](https://docs.rs/rosetta-build/). 40 | 41 | ## Contributing 42 | There is no particular contribution guidelines, feel free to open a new PR to improve the code. If you want to introduce a new feature, please create an issue before. 43 | -------------------------------------------------------------------------------- /book/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | title = "Rosetta Documentation" 3 | description = "Easy to use Rust i18n library based on code generation" 4 | authors = ["baptiste0928"] 5 | language = "en" 6 | src = "src" 7 | 8 | [build] 9 | build-dir = "output" 10 | 11 | [output.html] 12 | git-repository-url = "https://github.com/baptiste0928/rosetta" 13 | edit-url-template = "https://github.com/baptiste0928/rosetta/edit/main/book/{path}" 14 | -------------------------------------------------------------------------------- /book/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Overview](./overview.md) 4 | - [Getting started](./getting_started.md) 5 | - [Optional features](./optional_features.md) 6 | 7 | # Reference 8 | - [Build options](./reference/build_options.md) 9 | - [JSON file format](./reference/json_format.md) 10 | 11 | # Usage tips 12 | - [VS Code extensions](./tips/extensions.md) 13 | 14 | -------------------------------------------------------------------------------- /book/src/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | The following guide explains how to use `rosetta-i18` and `rosetta-build` to manage your translations. 4 | Please refer to other sections in this documentation or to the API documentation for in depth explanations. 5 | 6 | ## Installation 7 | Rosetta is separated into two crates: `rosetta-i18n` and `rosetta-build`. To install both, add the following to your `Cargo.toml`: 8 | 9 | ```toml 10 | [dependencies] 11 | rosetta-i18n = "0.1" 12 | 13 | [build-dependencies] 14 | rosetta-build = "0.1" 15 | ``` 16 | 17 | `rosetta-build` is used inside a build script and must be a build dependency. 18 | 19 | ## Writing translations files 20 | Rosetta use JSON translation files, which is similar to the one used by many other translation libraries and this widely supported. 21 | We need to put these files somewhere, for example in a `/locales` directory. 22 | 23 | `locales/en.json` 24 | ```json 25 | { 26 | "hello": "Hello world!", 27 | "hello_name": "Hello {name}!" 28 | } 29 | ``` 30 | 31 | In this example, we defined two keys, `hello` and `hello_name`. The first is a static string, whereas the second contains the `name` variable which will be 32 | replaced at runtime by the value of your choice. 33 | 34 | Create a file for each language you want to be supported by your application. It is not required that all files contain all keys: we will define the fallback language later. 35 | 36 | ## Generating code from translation files 37 | It is now that the magic happens. Rosetta lets you generate a Rust type from your translation files. 38 | For that, it use a [build script](https://doc.rust-lang.org/cargo/reference/build-scripts.html) which will be run each time you edit a translation file. 39 | 40 | We need to create a `build.rs` file at the root folder of the crate (same folder as the `Cargo.toml` file). 41 | 42 | `build.rs` 43 | ```rust 44 | fn main() -> Result<(), Box> { 45 | rosetta_build::config() 46 | .source("en", "locales/en.json") 47 | .source("fr", "locales/fr.json") 48 | .fallback("en") 49 | .generate()?; 50 | 51 | Ok(()) 52 | } 53 | ``` 54 | 55 | This script use the `rosetta_build` crate. In this example, we define two languages with [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) 56 | language identifiers: `en` and `fr`. The `en` language is defined as fallback and will be used if a key is not defined in other languages. 57 | 58 | The `.generate()` method is responsible for code generation. By default, the output file will be generated in a folder inside the `target` directory (`OUT_DIR` env variable). 59 | 60 | ## Using the generated type 61 | The generated type (named `Lang` except if you defined another name - see the previous section) must be included in your code with the `include_translations` 62 | macro. A good practice is to isolate it in a dedicated module. 63 | 64 | Each translation key is transformed into a method, and each language into an enum variant. Parameters are sorted alphabetically to avoid silent breaking changes 65 | when reordering. 66 | 67 | `src/main.rs` 68 | ```rust 69 | mod translations { 70 | rosetta_i18n::include_translations!(); 71 | } 72 | 73 | fn main() { 74 | use translations::Lang; 75 | 76 | println!("{}", Lang::En.hello()); // Hello world! 77 | println!("{}", Lang::En.hello_name("Rust")); // Hello Rust! 78 | } 79 | ``` 80 | 81 | -------------------------------------------------------------------------------- /book/src/optional_features.md: -------------------------------------------------------------------------------- 1 | # Optional features 2 | 3 | The `rosetta-i18n` and `rosetta-build` crates allow to turn on some additional features with [Cargo features](https://doc.rust-lang.org/cargo/reference/features.html). 4 | Most of these requires additional dependencies and are not enabled by default. 5 | 6 | > To enable a feature, you need to add a `feature` key in the `Cargo.toml` file like the following example: 7 | > 8 | > ```toml 9 | > rosetta-i18n = { version = "0.1", features = ["serde"] } 10 | > ``` 11 | 12 | ## `rosetta-i18n` 13 | 14 | - `serde`: enable [Serde](https://serde.rs/) support, providing `Serialize` and `Deserialize` implementation for some types. Utility functions to serialize and deserialize 15 | generated types are also provided. 16 | 17 | ## `rosetta-build` 18 | 19 | - `rustfmt` *(enabled by default)*: format generated code with [rustfmt](https://github.com/rust-lang/rustfmt). Disable this feature if `rustfmt` is not installed in your computer. 20 | -------------------------------------------------------------------------------- /book/src/overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Rosetta is an easy-to-use Rust [internationalization](https://en.wikipedia.org/wiki/Internationalization_and_localization) 4 | library powered by code generation. Unlike other libraries, translation files are parsed and embedded into the 5 | resulting binary at build-time. This provide a better developer experience and reduce runtime overheat. 6 | 7 | 8 | Using your translation files in your project is (almost) as easy as that: 9 | ```rust 10 | rosetta_i18n::include_translations!(); 11 | 12 | println!("{}", Lang::En.hello("world")); // Hello, world! 13 | ``` 14 | 15 | The following documentation aims to provide an exhaustive guide about using Rosetta in your project. 16 | See [Getting started](./getting_started.md) to get an usage overview. 17 | 18 | ## Related links 19 | Here are all the links related to the project: 20 | 21 | - **[GitHub repository](https://github.com/baptiste0928/rosetta)** - where the development happen, feel free to contribute! 22 | - [`rosetta-i18n` on crates.io](https://crates.io/crates/rosetta-i18n) - main crate containing all useful runtime features. 23 | - [`rosetta-build` on crates.io](https://crates.io/crates/rosetta-build) - crate used for code generation. 24 | - [`rosetta-i18n`](https://docs.rs/rosetta-i18n/) and [`rosetta-build`](https://docs.rs/rosetta-build/) on **docs.rs** - up-to-date API documentation. 25 | 26 | > Please give a ⭐ to the GitHub repository if you use Rosetta. 27 | 28 | ## Support 29 | If you encounter bugs or need help using Rosetta, here's what to do: 30 | 31 | - **If you need help with Rosetta**, [open a new discussion](https://github.com/baptiste0928/rosetta/discussions) on the GitHub repository. 32 | - **To report a bug or suggest a new feature**, [open a new issue](https://github.com/baptiste0928/rosetta/issues) on the GitHub repository. 33 | 34 | Please do not open issues for help request, this is not the right place for it. Use discussions instead. 35 | 36 | ## Contributing 37 | Rosetta is free and open-source. You can find the source code on GitHub and open a new issue to report bug or request features. 38 | If you want to improve the code or the documentation, consider opening a [pull request](https://github.com/baptiste0928/rosetta/pulls). 39 | 40 | Any contribution is welcome, even the smallest! 🙌 41 | -------------------------------------------------------------------------------- /book/src/reference/build_options.md: -------------------------------------------------------------------------------- 1 | # Build options 2 | 3 | The following is an exhaustive reference of all configurable build options. 4 | 5 | These options are provided as methods of the [`RosettaBuilder`](https://docs.rs/rosetta-build/*/rosetta_build/struct.RosettaBuilder.html) type. 6 | 7 | **Required options :** 8 | - [`.fallback()`](https://docs.rs/rosetta-build/*/rosetta_build/struct.RosettaBuilder.html#method.fallback): register the fallback language with a given language identifier and path 9 | - [`.source()`](https://docs.rs/rosetta-build/*/rosetta_build/struct.RosettaBuilder.html#method.source): register an additional translation source with a given language identifier and path 10 | 11 | **Additional options :** 12 | - [`.name()`](https://docs.rs/rosetta-build/*/rosetta_build/struct.RosettaBuilder.html#method.name): use a custom name for the generate type (`Lang` by default) 13 | - [`.output()`](https://docs.rs/rosetta-build/*/rosetta_build/struct.RosettaBuilder.html#method.output): export the type in another output location (`OUT_DIR` by default) 14 | 15 | More information in the [`RosettaBuilder` API documentation](https://docs.rs/rosetta-build/*/rosetta_build/struct.RosettaBuilder.html). 16 | -------------------------------------------------------------------------------- /book/src/reference/json_format.md: -------------------------------------------------------------------------------- 1 | # JSON file format 2 | 3 | The following is an exhaustive reference of the [JSON](https://en.wikipedia.org/wiki/JSON) file format used for translations. 4 | 5 | > **Note:** nested keys are not yet available. 6 | 7 | ## Simple key 8 | A simple translation key is a static string key without any variable interpolation. The `{` and `}` characters are not allowed. 9 | 10 | ```json 11 | { 12 | "simple": "Hello world!" 13 | } 14 | ``` 15 | 16 | ## String formatting 17 | You can add variables inside keys to insert dynamic content at runtime. Variable name should be in `snake_case` surrounded by `{` and `}` characters. 18 | 19 | ```json 20 | { 21 | "formatted": "I like three things: {first}, {second} and Rust." 22 | } 23 | ``` 24 | 25 | You can add as many parameters as you want. The same parameter can be inserted several times. 26 | Languages that are not fallback languages **must** have the same parameters as the fallback language. 27 | -------------------------------------------------------------------------------- /book/src/tips/extensions.md: -------------------------------------------------------------------------------- 1 | # Useful extensions 2 | 3 | If you are using [Visual Studio Code](https://code.visualstudio.com/), here is some useful extensions you can use. 4 | 5 | ## Using with Rust Analyzer 6 | [rust-analyzer](https://rust-analyzer.github.io/) is a popular extension providing completion and more for Rust files. 7 | If the generated code is not correctly loaded, add the following line to the `.vscode/settings.json` file: 8 | 9 | ```json 10 | "rust-analyzer.cargo.loadOutDirsFromCheck": true 11 | ``` 12 | 13 | ## Using with i18n ally 14 | [i18n ally](https://github.com/lokalise/i18n-ally) is an all-in-one i18n extension for VS Code that provide inline annotation of translated strings in your code. 15 | 16 | ![i18n ally example usage](./i18n-ally.png) 17 | 18 | *i18n ally* supports [custom translations frameworks](https://github.com/lokalise/i18n-ally/wiki/Custom-Framework) by adding a simple config file. 19 | Because code generated by Rosetta looks like any Rust method, the following configuration will consider that any method of a variable named `lang` 20 | or an enum named `Lang` is a translation key. It's not perfect as trait methods are also considered by the extension as translations keys, but it 21 | work well in most case. 22 | 23 | Create a `.vscode/i18n-ally-custom-framework.yml` file with the following content to enable Rosetta support. Edit this configuration if you are not 24 | using `lang` as variable name. 25 | ```yaml 26 | # .vscode/i18n-ally-custom-framework.yml 27 | languageIds: 28 | - rust 29 | 30 | usageMatchRegex: 31 | - "[^\\w\\d]Lang::[A-z]+\\.([a-z_]+)\\(.*\\)" 32 | - "[^\\w\\d]lang\\.([a-z_]+)\\(.*\\)" 33 | 34 | # If set to true, only enables this custom framework (will disable all built-in frameworks) 35 | monopoly: true 36 | ``` 37 | -------------------------------------------------------------------------------- /book/src/tips/i18n-ally.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baptiste0928/rosetta/5323b763be9970d4f3bee7f779a7ecefe33700f4/book/src/tips/i18n-ally.png -------------------------------------------------------------------------------- /rosetta-build/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rosetta-build" 3 | version = "0.1.3" 4 | description = "Code generation for the Rosetta i18n library." 5 | categories = ["internationalization", "development-tools::build-utils", "parsing"] 6 | keywords = ["i18n"] 7 | authors = ["baptiste0928"] 8 | readme = "README.md" 9 | homepage = "https://baptiste0928.github.io/rosetta/" 10 | repository = "https://github.com/baptiste0928/rosetta" 11 | documentation = "https://docs.rs/rosetta-build" 12 | license = "ISC" 13 | edition = "2018" 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [dependencies] 18 | convert_case = "0.4" 19 | lazy_static = "1.4" 20 | proc-macro2 = "1" 21 | quote = "1" 22 | regex = "1.5" 23 | tinyjson = "2" 24 | 25 | [features] 26 | default = ["rustfmt"] 27 | rustfmt = [] 28 | 29 | [dev-dependencies] 30 | maplit = "1" 31 | -------------------------------------------------------------------------------- /rosetta-build/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /rosetta-build/src/builder.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | env, 4 | fmt::{self, Display}, 5 | fs::File, 6 | io::Write, 7 | path::{Path, PathBuf}, 8 | str::FromStr, 9 | }; 10 | 11 | use tinyjson::JsonValue; 12 | 13 | use crate::{ 14 | error::{BuildError, ConfigError}, 15 | gen, parser, 16 | }; 17 | 18 | /// Helper function that return an default [`RosettaBuilder`]. 19 | pub fn config() -> RosettaBuilder { 20 | RosettaBuilder::default() 21 | } 22 | 23 | /// Builder used to configure Rosetta code generation. 24 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 25 | pub struct RosettaBuilder { 26 | files: HashMap, 27 | fallback: Option, 28 | name: Option, 29 | output: Option, 30 | } 31 | 32 | impl RosettaBuilder { 33 | /// Register a new translation source 34 | pub fn source(mut self, lang: impl Into, path: impl Into) -> Self { 35 | self.files.insert(lang.into(), PathBuf::from(path.into())); 36 | self 37 | } 38 | 39 | /// Register the fallback locale 40 | pub fn fallback(mut self, lang: impl Into) -> Self { 41 | self.fallback = Some(lang.into()); 42 | self 43 | } 44 | 45 | /// Define a custom name for the output type 46 | pub fn name(mut self, name: impl Into) -> Self { 47 | self.name = Some(name.into()); 48 | self 49 | } 50 | 51 | /// Change the default output of generated files 52 | pub fn output(mut self, path: impl Into) -> Self { 53 | self.output = Some(path.into()); 54 | self 55 | } 56 | 57 | /// Generate locale files and write them to the output location 58 | pub fn generate(self) -> Result<(), BuildError> { 59 | self.build()?.generate()?; 60 | Ok(()) 61 | } 62 | 63 | /// Validate configuration and build a [`RosettaConfig`] 64 | fn build(self) -> Result { 65 | let mut files: HashMap = self 66 | .files 67 | .into_iter() 68 | .map(|(lang, path)| { 69 | let lang = lang.parse::()?; 70 | Ok((lang, path)) 71 | }) 72 | .collect::>()?; 73 | 74 | if files.is_empty() { 75 | return Err(ConfigError::MissingSource); 76 | } 77 | 78 | let fallback = match self.fallback { 79 | Some(lang) => { 80 | let lang = lang.parse::()?; 81 | 82 | match files.remove_entry(&lang) { 83 | Some(entry) => entry, 84 | None => return Err(ConfigError::InvalidFallback), 85 | } 86 | } 87 | None => return Err(ConfigError::MissingFallback), 88 | }; 89 | 90 | Ok(RosettaConfig { 91 | fallback, 92 | others: files, 93 | name: self.name.unwrap_or_else(|| "Lang".to_string()), 94 | output: self.output, 95 | }) 96 | } 97 | } 98 | 99 | /// ISO 639-1 language identifier. 100 | /// 101 | /// Language identifier can be validated using the [`FromStr`] trait. 102 | /// It only checks if the string *looks like* a language identifier (2 character alphanumeric ascii string). 103 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 104 | pub(crate) struct LanguageId(pub String); 105 | 106 | impl LanguageId { 107 | pub(crate) fn value(&self) -> &str { 108 | &self.0 109 | } 110 | } 111 | 112 | impl FromStr for LanguageId { 113 | type Err = ConfigError; 114 | 115 | fn from_str(s: &str) -> Result { 116 | let valid_length = s.len() == 2; 117 | let ascii_alphabetic = s.chars().all(|c| c.is_ascii_alphabetic()); 118 | 119 | if valid_length && ascii_alphabetic { 120 | Ok(Self(s.to_ascii_lowercase())) 121 | } else { 122 | Err(ConfigError::InvalidLanguage(s.into())) 123 | } 124 | } 125 | } 126 | 127 | impl Display for LanguageId { 128 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 129 | write!(f, "{}", self.0) 130 | } 131 | } 132 | 133 | /// Configuration for Rosetta code generation 134 | /// 135 | /// A [`RosettaBuilder`] is provided to construct and validate configuration. 136 | #[derive(Debug, Clone, PartialEq, Eq)] 137 | pub(crate) struct RosettaConfig { 138 | pub fallback: (LanguageId, PathBuf), 139 | pub others: HashMap, 140 | pub name: String, 141 | pub output: Option, 142 | } 143 | 144 | impl RosettaConfig { 145 | /// Returns a list of the languages 146 | pub fn languages(&self) -> Vec<&LanguageId> { 147 | let mut languages: Vec<&LanguageId> = 148 | self.others.iter().map(|(language, _)| language).collect(); 149 | languages.push(&self.fallback.0); 150 | languages 151 | } 152 | 153 | /// Generate locale files and write them to the output location 154 | pub fn generate(&self) -> Result<(), BuildError> { 155 | let fallback_content = open_file(&self.fallback.1)?; 156 | let mut parsed = parser::TranslationData::from_fallback(fallback_content)?; 157 | println!( 158 | "cargo:rerun-if-changed={}", 159 | self.fallback.1.to_string_lossy() 160 | ); 161 | 162 | for (language, path) in &self.others { 163 | let content = open_file(path)?; 164 | parsed.parse_file(language.clone(), content)?; 165 | println!("cargo:rerun-if-changed={}", path.to_string_lossy()); 166 | } 167 | 168 | let generated = gen::CodeGenerator::new(&parsed, self).generate(); 169 | 170 | let output = match &self.output { 171 | Some(path) => path.clone(), 172 | None => Path::new(&env::var("OUT_DIR")?).join("rosetta_output.rs"), 173 | }; 174 | 175 | let mut file = File::create(&output)?; 176 | file.write_all(generated.to_string().as_bytes())?; 177 | 178 | #[cfg(feature = "rustfmt")] 179 | rustfmt(&output)?; 180 | 181 | Ok(()) 182 | } 183 | } 184 | 185 | /// Open a file and read its content as a JSON [`JsonValue`] 186 | fn open_file(path: &Path) -> Result { 187 | let content = match std::fs::read_to_string(path) { 188 | Ok(content) => content, 189 | Err(error) => { 190 | return Err(BuildError::FileRead { 191 | file: path.to_path_buf(), 192 | source: error, 193 | }) 194 | } 195 | }; 196 | 197 | match content.parse::() { 198 | Ok(parsed) => Ok(parsed), 199 | Err(error) => Err(BuildError::JsonParse { 200 | file: path.to_path_buf(), 201 | source: error, 202 | }), 203 | } 204 | } 205 | 206 | /// Format a file with rustfmt 207 | #[cfg(feature = "rustfmt")] 208 | fn rustfmt(path: &Path) -> Result<(), BuildError> { 209 | use std::process::Command; 210 | 211 | Command::new(env::var("RUSTFMT").unwrap_or_else(|_| "rustfmt".to_string())) 212 | .args(&["--emit", "files"]) 213 | .arg(path) 214 | .output() 215 | .map_err(BuildError::Fmt)?; 216 | 217 | Ok(()) 218 | } 219 | 220 | #[cfg(test)] 221 | mod tests { 222 | use super::RosettaConfig; 223 | use crate::{ 224 | builder::{LanguageId, RosettaBuilder}, 225 | error::ConfigError, 226 | }; 227 | 228 | use std::path::PathBuf; 229 | 230 | use maplit::hashmap; 231 | 232 | #[test] 233 | fn config_simple() -> Result<(), Box> { 234 | let config = RosettaBuilder::default() 235 | .source("en", "translations/en.json") 236 | .source("fr", "translations/fr.json") 237 | .fallback("en") 238 | .build()?; 239 | 240 | let expected = RosettaConfig { 241 | fallback: ( 242 | LanguageId("en".into()), 243 | PathBuf::from("translations/en.json"), 244 | ), 245 | others: hashmap! { LanguageId("fr".into()) => PathBuf::from("translations/fr.json") }, 246 | name: "Lang".to_string(), 247 | output: None, 248 | }; 249 | 250 | assert_eq!(config, expected); 251 | 252 | Ok(()) 253 | } 254 | 255 | #[test] 256 | fn config_missing_source() { 257 | let config = RosettaBuilder::default().build(); 258 | assert_eq!(config, Err(ConfigError::MissingSource)); 259 | } 260 | 261 | #[test] 262 | fn config_invalid_language() { 263 | let config = RosettaBuilder::default() 264 | .source("en", "translations/en.json") 265 | .source("invalid", "translations/fr.json") 266 | .fallback("en") 267 | .build(); 268 | 269 | assert_eq!( 270 | config, 271 | Err(ConfigError::InvalidLanguage("invalid".to_string())) 272 | ); 273 | } 274 | 275 | #[test] 276 | fn config_missing_fallback() { 277 | let config = RosettaBuilder::default() 278 | .source("en", "translations/en.json") 279 | .source("fr", "translations/fr.json") 280 | .build(); 281 | 282 | assert_eq!(config, Err(ConfigError::MissingFallback)); 283 | } 284 | 285 | #[test] 286 | fn config_invalid_fallback() { 287 | let config = RosettaBuilder::default() 288 | .source("en", "translations/en.json") 289 | .source("fr", "translations/fr.json") 290 | .fallback("de") 291 | .build(); 292 | 293 | assert_eq!(config, Err(ConfigError::InvalidFallback)); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /rosetta-build/src/error.rs: -------------------------------------------------------------------------------- 1 | //! Errors returned when generating code. 2 | 3 | use std::{ 4 | error::Error, 5 | fmt::{self, Display}, 6 | path::PathBuf, 7 | }; 8 | 9 | /// Error type returned when the configuration passed to [`RosettaBuilder`] is invalid. 10 | /// 11 | /// [`RosettaBuilder`]: crate::RosettaBuilder 12 | #[derive(Debug, Clone, PartialEq, Eq)] 13 | pub enum ConfigError { 14 | /// Invalid language identifier 15 | InvalidLanguage(String), 16 | /// No source provided 17 | MissingSource, 18 | /// No fallback language provided 19 | MissingFallback, 20 | /// The fallback language doesn't match any source 21 | InvalidFallback, 22 | } 23 | 24 | impl Error for ConfigError {} 25 | 26 | impl Display for ConfigError { 27 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 28 | match self { 29 | ConfigError::InvalidLanguage(value) => { 30 | write!(f, "`{}` is not a valid language identifier", value) 31 | } 32 | ConfigError::MissingSource => { 33 | write!(f, "at least one translations source file is required") 34 | } 35 | ConfigError::MissingFallback => write!(f, "a fallback language must be provided"), 36 | ConfigError::InvalidFallback => write!( 37 | f, 38 | "no source corresponding to the fallback language was found" 39 | ), 40 | } 41 | } 42 | } 43 | 44 | /// Error type returned when the code generation failed for some reason. 45 | #[derive(Debug)] 46 | pub enum BuildError { 47 | Config(ConfigError), 48 | FileRead { 49 | file: PathBuf, 50 | source: std::io::Error, 51 | }, 52 | FileWrite(std::io::Error), 53 | JsonParse { 54 | file: PathBuf, 55 | source: tinyjson::JsonParseError, 56 | }, 57 | Parse(ParseError), 58 | Var(std::env::VarError), 59 | Fmt(std::io::Error), 60 | } 61 | 62 | impl Error for BuildError {} 63 | 64 | impl Display for BuildError { 65 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 66 | match self { 67 | BuildError::Config(error) => write!(f, "invalid configuration: {}", error), 68 | BuildError::FileRead { file, source } => { 69 | write!(f, "failed to read `{:?}`: {}", file, source) 70 | } 71 | BuildError::FileWrite(error) => write!(f, "failed to write output: {}", error), 72 | BuildError::JsonParse { file, source } => { 73 | write!(f, "failed to load {:?}: {}", file, source) 74 | } 75 | BuildError::Parse(error) => write!(f, "failed to parse translations: {}", error), 76 | BuildError::Var(error) => write!(f, "failed to read environment variable: {}", error), 77 | BuildError::Fmt(error) => write!(f, "failed to run rustfmt: {}", error), 78 | } 79 | } 80 | } 81 | 82 | impl From for BuildError { 83 | fn from(error: ConfigError) -> Self { 84 | Self::Config(error) 85 | } 86 | } 87 | 88 | impl From for BuildError { 89 | fn from(error: ParseError) -> Self { 90 | Self::Parse(error) 91 | } 92 | } 93 | 94 | impl From for BuildError { 95 | fn from(error: std::io::Error) -> Self { 96 | Self::FileWrite(error) 97 | } 98 | } 99 | 100 | impl From for BuildError { 101 | fn from(error: std::env::VarError) -> Self { 102 | Self::Var(error) 103 | } 104 | } 105 | 106 | /// Error type returned when a parsing error occurs. 107 | #[derive(Debug, Clone, PartialEq, Eq)] 108 | pub enum ParseError { 109 | /// File root is not a JSON object 110 | InvalidRoot, 111 | /// Invalid key type (raw parsing) 112 | InvalidValue { key: String }, 113 | /// Invalid key type (doesn't match previous parsed keys) 114 | InvalidType { key: String, expected: &'static str }, 115 | /// Invalid parameters supplied to interpolated key (missing and/or unknown parameters) 116 | InvalidParameters { 117 | key: String, 118 | missing: Vec, 119 | unknown: Vec, 120 | }, 121 | /// Invalid language identifier (not ISO 693-1 compliant) 122 | InvalidLanguageId { value: String }, 123 | } 124 | 125 | impl Error for ParseError {} 126 | 127 | impl Display for ParseError { 128 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 129 | match self { 130 | ParseError::InvalidRoot => write!(f, "file root must be a json object"), 131 | ParseError::InvalidValue { key } => write!(f, "`{}` has an invalid type", key), 132 | ParseError::InvalidType { key, expected } => write!( 133 | f, 134 | "`{}` doesn't match previous parsed key (expected {})", 135 | key, expected 136 | ), 137 | ParseError::InvalidParameters { 138 | key, 139 | missing, 140 | unknown, 141 | } => write!( 142 | f, 143 | "invalid parameters supplied to `{}` (missing: {:?}, unknown: {:?})", 144 | key, missing, unknown 145 | ), 146 | ParseError::InvalidLanguageId { value } => write!( 147 | f, 148 | "`{}` is not a valid ISO 693-1 language identifier", 149 | value 150 | ), 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /rosetta-build/src/gen.rs: -------------------------------------------------------------------------------- 1 | //! Code generation 2 | //! 3 | //! # Generated code 4 | //! The generated code consists of a single enum (called by default `Lang`), 5 | //! which expose pub(crate)lic method for each of the translation keys. These 6 | //! methods returns a `&'static str` where possible, otherwise a `String`. 7 | //! 8 | //! # Usage 9 | //! The code generator is contained within the [`CodeGenerator`] struct. 10 | //! Calling [`generate`](CodeGenerator::generate) will produce a [TokenStream] 11 | //! with the generated code. Internal methods used to generate the output are not exposed. 12 | 13 | use std::{ 14 | collections::{HashMap, HashSet}, 15 | iter::FromIterator, 16 | }; 17 | 18 | use convert_case::{Case, Casing}; 19 | use proc_macro2::{Ident, Span, TokenStream}; 20 | use quote::quote; 21 | 22 | use crate::{ 23 | builder::{LanguageId, RosettaConfig}, 24 | parser::{FormattedKey, SimpleKey, TranslationData, TranslationKey}, 25 | }; 26 | 27 | /// Type storing state and configuration for the code generator 28 | #[derive(Debug, Clone, PartialEq, Eq)] 29 | pub(crate) struct CodeGenerator<'a> { 30 | keys: &'a HashMap, 31 | languages: Vec<&'a LanguageId>, 32 | fallback: &'a LanguageId, 33 | name: Ident, 34 | } 35 | 36 | impl<'a> CodeGenerator<'a> { 37 | /// Initialize a new [`CodeGenerator`] 38 | pub(crate) fn new(data: &'a TranslationData, config: &'a RosettaConfig) -> Self { 39 | let name = Ident::new(&config.name, Span::call_site()); 40 | 41 | CodeGenerator { 42 | keys: &data.keys, 43 | languages: config.languages(), 44 | fallback: &config.fallback.0, 45 | name, 46 | } 47 | } 48 | 49 | /// Generate code as a [`TokenStream`] 50 | pub(crate) fn generate(&self) -> TokenStream { 51 | // Transform as PascalCase strings 52 | let languages: Vec<_> = self 53 | .languages 54 | .iter() 55 | .map(|lang| lang.value().to_case(Case::Pascal)) 56 | .collect(); 57 | 58 | let name = &self.name; 59 | let fields = languages 60 | .iter() 61 | .map(|lang| Ident::new(lang, Span::call_site())); 62 | 63 | let language_impl = self.impl_language(); 64 | let methods = self.keys.iter().map(|(key, value)| match value { 65 | TranslationKey::Simple(inner) => self.method_simple(key, inner), 66 | TranslationKey::Formatted(inner) => self.method_formatted(key, inner), 67 | }); 68 | 69 | quote! { 70 | /// Language type generated by the [rosetta](https://github.com/baptiste0928/rosetta) i18n library. 71 | #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] 72 | pub enum #name { 73 | #(#fields),* 74 | } 75 | 76 | impl #name { 77 | #(#methods)* 78 | } 79 | 80 | #language_impl 81 | } 82 | } 83 | 84 | /// Generate method for [`TranslationKey::Simple`] 85 | fn method_simple(&self, key: &str, data: &SimpleKey) -> TokenStream { 86 | let name = Ident::new(&key.to_case(Case::Snake), Span::call_site()); 87 | let fallback = &data.fallback; 88 | let arms = data 89 | .others 90 | .iter() 91 | .map(|(language, value)| self.match_arm_simple(language, value)); 92 | 93 | quote! { 94 | #[allow(clippy::all)] 95 | pub fn #name(&self) -> &'static str { 96 | match self { 97 | #(#arms,)* 98 | _ => #fallback 99 | } 100 | } 101 | } 102 | } 103 | 104 | /// Generate match arm for [`TranslationKey::Simple`] 105 | fn match_arm_simple(&self, language: &LanguageId, value: &str) -> TokenStream { 106 | let name = &self.name; 107 | let lang = Ident::new(&language.value().to_case(Case::Pascal), Span::call_site()); 108 | 109 | quote! { #name::#lang => #value } 110 | } 111 | 112 | /// Generate method for [`TranslationKey::Formatted`] 113 | fn method_formatted(&self, key: &str, data: &FormattedKey) -> TokenStream { 114 | let name = Ident::new(&key.to_case(Case::Snake), Span::call_site()); 115 | 116 | // Sort parameters alphabetically to have consistent ordering 117 | let mut sorted = Vec::from_iter(&data.parameters); 118 | sorted.sort_by_key(|s| s.to_lowercase()); 119 | let params = sorted 120 | .iter() 121 | .map(|param| Ident::new(param, Span::call_site())) 122 | .map(|param| quote!(#param: impl ::std::fmt::Display)); 123 | 124 | let arms = data 125 | .others 126 | .iter() 127 | .map(|(language, value)| self.match_arm_formatted(language, value, &data.parameters)); 128 | let fallback = self.format_formatted(&data.fallback, &data.parameters); 129 | 130 | quote! { 131 | #[allow(clippy::all)] 132 | pub fn #name(&self, #(#params),*) -> ::std::string::String { 133 | match self { 134 | #(#arms,)* 135 | _ => #fallback 136 | } 137 | } 138 | } 139 | } 140 | 141 | /// Generate match arm for [`TranslationKey::Formatted`] 142 | fn match_arm_formatted( 143 | &self, 144 | language: &LanguageId, 145 | value: &str, 146 | parameters: &HashSet, 147 | ) -> TokenStream { 148 | let name = &self.name; 149 | let format_value = self.format_formatted(value, parameters); 150 | let lang = Ident::new(&language.value().to_case(Case::Pascal), Span::call_site()); 151 | 152 | quote! { #name::#lang => #format_value } 153 | } 154 | 155 | /// Generate `format!` for [`TranslationKey::Formatted`] 156 | fn format_formatted(&self, value: &str, parameters: &HashSet) -> TokenStream { 157 | let params = parameters 158 | .iter() 159 | .map(|param| Ident::new(param, Span::call_site())) 160 | .map(|param| quote!(#param = #param)); 161 | 162 | quote!(format!(#value, #(#params),*)) 163 | } 164 | 165 | /// Generate implementation for `rosetta_i18n::Language` trait. 166 | fn impl_language(&self) -> TokenStream { 167 | let name = &self.name; 168 | let fallback = Ident::new( 169 | &self.fallback.value().to_case(Case::Pascal), 170 | Span::call_site(), 171 | ); 172 | 173 | let language_id_idents = self.languages.iter().map(|lang| lang.value()).map(|lang| { 174 | ( 175 | lang, 176 | Ident::new(&lang.to_case(Case::Pascal), Span::call_site()), 177 | ) 178 | }); 179 | 180 | let from_language_id_arms = language_id_idents 181 | .clone() 182 | .map(|(lang, ident)| quote!(#lang => ::core::option::Option::Some(Self::#ident))); 183 | 184 | let to_language_id_arms = language_id_idents 185 | .map(|(lang, ident)| quote!(Self::#ident => ::rosetta_i18n::LanguageId::new(#lang))); 186 | 187 | quote! { 188 | impl ::rosetta_i18n::Language for #name { 189 | fn from_language_id(language_id: &::rosetta_i18n::LanguageId) -> ::core::option::Option { 190 | match language_id.value() { 191 | #(#from_language_id_arms,)* 192 | _ => ::core::option::Option::None 193 | } 194 | } 195 | 196 | fn language_id(&self) -> ::rosetta_i18n::LanguageId { 197 | match self { 198 | #(#to_language_id_arms,)* 199 | } 200 | } 201 | 202 | fn fallback() -> Self { 203 | Self::#fallback 204 | } 205 | } 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /rosetta-build/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Code generation for the Rosetta i18n library. 2 | //! 3 | //! # Usage 4 | //! Code generation works within [build script]. You only need to configure source files and 5 | //! the fallback language. Please read the [documentation] for more information. 6 | //! 7 | //! ```no_run 8 | //! rosetta_build::config() 9 | //! .source("fr", "locales/fr.json") 10 | //! .source("en", "locales/en.json") 11 | //! .fallback("en") 12 | //! .generate(); 13 | //! ``` 14 | //! 15 | //! [build script]: https://doc.rust-lang.org/cargo/reference/build-scripts.html 16 | //! [documentation]: https://baptiste0928.github.io/rosetta/ 17 | 18 | pub mod error; 19 | 20 | mod builder; 21 | mod gen; 22 | mod parser; 23 | 24 | pub use crate::builder::{config, RosettaBuilder}; 25 | -------------------------------------------------------------------------------- /rosetta-build/src/parser.rs: -------------------------------------------------------------------------------- 1 | //! Translations files parsing 2 | //! 3 | //! Files are parsed as [TranslationData] from a provided [JsonValue]. 4 | //! Parsed keys are represented as [TranslationKey]. 5 | 6 | use std::collections::{HashMap, HashSet}; 7 | 8 | use lazy_static::lazy_static; 9 | use regex::Regex; 10 | use tinyjson::JsonValue; 11 | 12 | use crate::{builder::LanguageId, error::ParseError}; 13 | 14 | /// Data structure containing all translation keys 15 | /// 16 | /// This struct should be initialized with the fallback language, 17 | /// then keys will be populated with other languages using the [`parse_file`] method. 18 | /// 19 | /// [`parse_file`]: Self::parse_file 20 | #[derive(Debug, Clone, PartialEq, Eq)] 21 | pub(crate) struct TranslationData { 22 | /// Parsed translation keys 23 | pub(crate) keys: HashMap, 24 | } 25 | 26 | impl TranslationData { 27 | /// Initialize a [`TranslationData`] instance from the fallback language 28 | pub(crate) fn from_fallback(file: JsonValue) -> Result { 29 | let parsed = ParsedFile::parse(file)?; 30 | let keys = parsed 31 | .keys 32 | .into_iter() 33 | .map(|(key, value)| (key, TranslationKey::from_parsed(value))) 34 | .collect(); 35 | 36 | Ok(Self { keys }) 37 | } 38 | 39 | /// Parse a language file and insert its content into the current [`TranslationData`] 40 | pub(crate) fn parse_file( 41 | &mut self, 42 | language: LanguageId, 43 | file: JsonValue, 44 | ) -> Result<(), ParseError> { 45 | let parsed = ParsedFile::parse(file)?; 46 | 47 | for (key, parsed) in parsed.keys { 48 | match self.keys.get_mut(&key) { 49 | Some(translation_key) => { 50 | let data = ParsedKeyData { 51 | language: language.clone(), 52 | key: &key, 53 | parsed, 54 | }; 55 | translation_key.insert_parsed(data)? 56 | } 57 | None => println!( 58 | "cargo:warning=Key `{}` exists in {} but not in fallback language", 59 | key, language 60 | ), 61 | }; 62 | } 63 | 64 | Ok(()) 65 | } 66 | } 67 | 68 | /// A parsed translation key 69 | /// 70 | /// This enum can be constructed by parsing a translation file with [TranslationData]. 71 | #[derive(Debug, Clone, PartialEq, Eq)] 72 | pub(crate) enum TranslationKey { 73 | Simple(SimpleKey), 74 | Formatted(FormattedKey), 75 | } 76 | 77 | impl TranslationKey { 78 | /// Initialize a new [TranslationKey] from a [`ParsedKey`] 79 | fn from_parsed(parsed: ParsedKey) -> Self { 80 | match parsed { 81 | ParsedKey::Simple(value) => TranslationKey::Simple(SimpleKey { 82 | fallback: value, 83 | others: HashMap::new(), 84 | }), 85 | ParsedKey::Formatted { value, parameters } => TranslationKey::Formatted(FormattedKey { 86 | fallback: value, 87 | others: HashMap::new(), 88 | parameters, 89 | }), 90 | } 91 | } 92 | 93 | /// Inserts a new raw [`ParsedKey`] in this [`TranslationKey`] 94 | fn insert_parsed(&mut self, data: ParsedKeyData) -> Result<(), ParseError> { 95 | match self { 96 | TranslationKey::Simple(inner) => inner.insert_parsed(data), 97 | TranslationKey::Formatted(inner) => inner.insert_parsed(data), 98 | } 99 | } 100 | } 101 | 102 | #[derive(Debug, Clone, PartialEq, Eq)] 103 | /// Simple string key, without any formatting or plurals 104 | pub(crate) struct SimpleKey { 105 | /// The key value for the fallback language 106 | pub(crate) fallback: String, 107 | /// Key values for other languages 108 | pub(crate) others: HashMap, 109 | } 110 | 111 | impl SimpleKey { 112 | /// Inserts a new raw [`ParsedKey`] in this [`SimpleKey`] 113 | fn insert_parsed(&mut self, data: ParsedKeyData) -> Result<(), ParseError> { 114 | match data.parsed { 115 | ParsedKey::Simple(value) => self.others.insert(data.language, value), 116 | _ => { 117 | return Err(ParseError::InvalidType { 118 | key: data.key.into(), 119 | expected: "string", 120 | }) 121 | } 122 | }; 123 | 124 | Ok(()) 125 | } 126 | } 127 | 128 | #[derive(Debug, Clone, PartialEq, Eq)] 129 | /// Simple string key with formatting 130 | pub(crate) struct FormattedKey { 131 | /// The key value for the fallback language 132 | pub(crate) fallback: String, 133 | /// Key values for other languages 134 | pub(crate) others: HashMap, 135 | /// List of parameters in the value 136 | pub(crate) parameters: HashSet, 137 | } 138 | 139 | impl FormattedKey { 140 | /// Inserts a new [`ParsedKey`] in this [`SimpleKey`] 141 | fn insert_parsed(&mut self, data: ParsedKeyData) -> Result<(), ParseError> { 142 | let (value, parameters) = match data.parsed { 143 | ParsedKey::Formatted { value, parameters } => (value, parameters), 144 | _ => { 145 | return Err(ParseError::InvalidType { 146 | key: data.key.into(), 147 | expected: "formatted string", 148 | }) 149 | } 150 | }; 151 | 152 | if parameters == self.parameters { 153 | self.others.insert(data.language, value); 154 | Ok(()) 155 | } else { 156 | let missing: Vec<_> = self.parameters.difference(¶meters).cloned().collect(); 157 | let unknown: Vec<_> = parameters.difference(&self.parameters).cloned().collect(); 158 | 159 | Err(ParseError::InvalidParameters { 160 | key: data.key.into(), 161 | missing, 162 | unknown, 163 | }) 164 | } 165 | } 166 | } 167 | 168 | /// Raw representation of a parsed file 169 | #[derive(Debug, Clone, PartialEq, Eq)] 170 | struct ParsedFile { 171 | keys: HashMap, 172 | } 173 | 174 | impl ParsedFile { 175 | /// Parse a JSON [`JsonValue`] as a translations file 176 | fn parse(file: JsonValue) -> Result { 177 | let input = match file { 178 | JsonValue::Object(map) => map, 179 | _ => return Err(ParseError::InvalidRoot), 180 | }; 181 | 182 | let mut keys = HashMap::with_capacity(input.len()); 183 | for (key, value) in input { 184 | let parsed = ParsedKey::parse(&key, value)?; 185 | keys.insert(key, parsed); 186 | } 187 | 188 | Ok(ParsedFile { keys }) 189 | } 190 | } 191 | 192 | /// Raw representation of a parsed key 193 | #[derive(Debug, Clone, PartialEq, Eq)] 194 | enum ParsedKey { 195 | /// Simple string key 196 | Simple(String), 197 | /// String key with formatted values 198 | /// 199 | /// Example : `Hello {name}!` 200 | Formatted { 201 | /// The raw key value 202 | value: String, 203 | /// List of parameters in the value 204 | parameters: HashSet, 205 | }, 206 | } 207 | 208 | impl ParsedKey { 209 | /// Parse a JSON [`Value`] as a key 210 | fn parse(key: &str, value: JsonValue) -> Result { 211 | match value { 212 | JsonValue::String(value) => Ok(Self::parse_string(value)), 213 | _ => Err(ParseError::InvalidValue { key: key.into() }), 214 | } 215 | } 216 | 217 | fn parse_string(value: String) -> Self { 218 | lazy_static! { 219 | static ref RE: Regex = Regex::new(r"\{([a-z_]+)\}").unwrap(); 220 | } 221 | 222 | let matches: HashSet<_> = RE 223 | .captures_iter(&value) 224 | .map(|capture| capture[1].to_string()) 225 | .collect(); 226 | 227 | if matches.is_empty() { 228 | Self::Simple(value) 229 | } else { 230 | Self::Formatted { 231 | value, 232 | parameters: matches, 233 | } 234 | } 235 | } 236 | } 237 | 238 | /// Data associated with a parsed key. 239 | /// 240 | /// Used in [`TranslationKey::insert_parsed`]. 241 | #[derive(Debug, Clone, PartialEq, Eq)] 242 | struct ParsedKeyData<'a> { 243 | language: LanguageId, 244 | key: &'a str, 245 | parsed: ParsedKey, 246 | } 247 | 248 | #[cfg(test)] 249 | mod tests { 250 | use super::{TranslationData, TranslationKey}; 251 | use crate::{ 252 | builder::LanguageId, 253 | error::ParseError, 254 | parser::{FormattedKey, SimpleKey}, 255 | }; 256 | 257 | use maplit::{hashmap, hashset}; 258 | use tinyjson::JsonValue; 259 | 260 | macro_rules! json { 261 | ($value:tt) => { 262 | stringify!($value).parse::().unwrap() 263 | }; 264 | } 265 | 266 | #[test] 267 | fn parse_simple() -> Result<(), Box> { 268 | let en = json!({ "hello": "Hello world!" }); 269 | let fr = json!({ "hello": "Bonjour le monde !" }); 270 | 271 | let mut parsed = TranslationData::from_fallback(en)?; 272 | parsed.parse_file(LanguageId("fr".into()), fr)?; 273 | 274 | assert_eq!(parsed.keys.len(), 1); 275 | assert!(parsed.keys.get("hello").is_some()); 276 | 277 | let expected = TranslationKey::Simple(SimpleKey { 278 | fallback: "Hello world!".to_string(), 279 | others: hashmap! { 280 | LanguageId("fr".into()) => "Bonjour le monde !".to_string() 281 | }, 282 | }); 283 | 284 | assert_eq!(parsed.keys.get("hello").unwrap(), &expected); 285 | 286 | Ok(()) 287 | } 288 | 289 | #[test] 290 | fn parse_formatted() -> Result<(), Box> { 291 | let en = json!({ "hello": "Hello {name}!" }); 292 | let fr = json!({ "hello": "Bonjour {name} !" }); 293 | 294 | let mut parsed = TranslationData::from_fallback(en)?; 295 | parsed.parse_file(LanguageId("fr".into()), fr)?; 296 | 297 | assert_eq!(parsed.keys.len(), 1); 298 | assert!(parsed.keys.get("hello").is_some()); 299 | 300 | let expected = TranslationKey::Formatted(FormattedKey { 301 | fallback: "Hello {name}!".to_string(), 302 | others: hashmap! { 303 | LanguageId("fr".into()) => "Bonjour {name} !".to_string() 304 | }, 305 | parameters: hashset! { "name".to_string() }, 306 | }); 307 | 308 | assert_eq!(parsed.keys.get("hello").unwrap(), &expected); 309 | 310 | Ok(()) 311 | } 312 | 313 | #[test] 314 | fn parse_invalid_root() { 315 | let file = json!("invalid"); 316 | let parsed = TranslationData::from_fallback(file); 317 | assert_eq!(parsed, Err(ParseError::InvalidRoot)); 318 | } 319 | 320 | #[test] 321 | fn parse_invalid_value() { 322 | let file = json!({ "hello": ["Hello world!"] }); 323 | let parsed = TranslationData::from_fallback(file); 324 | assert_eq!( 325 | parsed, 326 | Err(ParseError::InvalidValue { 327 | key: "hello".to_string() 328 | }) 329 | ); 330 | } 331 | 332 | #[test] 333 | fn parse_invalid_parameter() { 334 | let en = json!({ "hello": "Hello {name}!" }); 335 | let fr = json!({ "hello": "Bonjour {surname} !" }); 336 | 337 | let mut parsed = TranslationData::from_fallback(en).unwrap(); 338 | let result = parsed.parse_file(LanguageId("fr".into()), fr); 339 | 340 | let expected = ParseError::InvalidParameters { 341 | key: "hello".to_string(), 342 | missing: vec!["name".to_string()], 343 | unknown: vec!["surname".to_string()], 344 | }; 345 | assert_eq!(result, Err(expected)); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /rosetta-i18n/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rosetta-i18n" 3 | version = "0.1.3" 4 | description = "Easy to use i18n library based on code generation." 5 | categories = ["internationalization", "development-tools::build-utils", "parsing"] 6 | keywords = ["i18n"] 7 | authors = ["baptiste0928"] 8 | readme = "README.md" 9 | homepage = "https://baptiste0928.github.io/rosetta/" 10 | repository = "https://github.com/baptiste0928/rosetta" 11 | documentation = "https://docs.rs/rosetta-i18n" 12 | license = "ISC" 13 | edition = "2018" 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [dependencies] 18 | serde = { version = "1", optional = true } 19 | 20 | [dev-dependencies] 21 | serde = { version = "1", features = ["derive"] } 22 | serde_test = "1" 23 | 24 | [package.metadata.docs.rs] 25 | all-features = true 26 | rustdoc-args = ["--cfg", "docsrs"] 27 | -------------------------------------------------------------------------------- /rosetta-i18n/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /rosetta-i18n/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Easy-to-use i18n library for Rust, based on code generation. 2 | //! 3 | //! ## Usage 4 | //! Please read the [documentation] to learn how to use this library. 5 | //! 6 | //! ```ignore 7 | //! mod translations { 8 | //! rosetta_i18n::include_translations!(); 9 | //! } 10 | //! 11 | //! fn main() { 12 | //! assert_eq!(Lang::En.hello(), "Hello world!"); 13 | //! } 14 | //! ``` 15 | //! 16 | //! ## Serde support 17 | //! This crate provide serialization and deserialization of languages types with Serde. 18 | //! The `serde` feature must be enabled. 19 | //! 20 | //! [documentation]: https://baptiste0928.github.io/rosetta/ 21 | #![cfg_attr(docsrs, feature(doc_cfg))] 22 | 23 | use std::borrow::Cow; 24 | 25 | #[doc(hidden)] 26 | pub mod provider; 27 | #[cfg(feature = "serde")] 28 | #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] 29 | pub mod serde_helpers; 30 | 31 | /// Include the generated translations. 32 | /// 33 | /// The generated code will be included in the file as if it were a direct element of it. 34 | /// It is recommended to wrap the generated code in its own module: 35 | /// 36 | /// ```ignore 37 | /// mod translations { 38 | /// rosetta_18n::include_translations!(); 39 | /// } 40 | /// ``` 41 | /// 42 | /// This only works if the `rosetta-build` output file has been unmodified. 43 | /// Otherwise, use the following pattern to include the file: 44 | /// 45 | /// ```ignore 46 | /// include!("/relative/path/to/rosetta_output.rs"); 47 | /// ``` 48 | #[macro_export] 49 | macro_rules! include_translations { 50 | () => { 51 | include!(concat!(env!("OUT_DIR"), "/rosetta_output.rs")); 52 | }; 53 | } 54 | 55 | /// Trait implemented by languages structs generated by `rosetta-build`. 56 | pub trait Language: Sized { 57 | /// Initialize this type from a [`LanguageId`]. 58 | /// 59 | /// The method returns [`None`] if the provided language id is not supported 60 | /// by the struct. 61 | fn from_language_id(language_id: &LanguageId) -> Option; 62 | /// Convert this struct to a [`LanguageId`]. 63 | fn language_id(&self) -> LanguageId; 64 | /// Get the fallback language of this type. 65 | /// 66 | /// This fallback value can be used like a default value. 67 | fn fallback() -> Self; 68 | } 69 | 70 | /// Generic language type that implement the [`Language`] trait. 71 | /// 72 | /// This type can be used as a default generic type when sharing models between multiple 73 | /// crates that does not necessarily use translations. 74 | /// 75 | /// ## Panics 76 | /// The [`fallback`] method of the [`Language`] trait is not implemented and will panic if called. 77 | /// 78 | /// [`fallback`]: Language::fallback 79 | pub struct GenericLanguage(String); 80 | 81 | impl Language for GenericLanguage { 82 | fn from_language_id(language_id: &LanguageId) -> Option { 83 | Some(Self(language_id.value().into())) 84 | } 85 | 86 | fn language_id(&self) -> LanguageId { 87 | LanguageId::new(&self.0) 88 | } 89 | 90 | fn fallback() -> Self { 91 | unimplemented!("GenericLanguage has no fallback language") 92 | } 93 | } 94 | 95 | /// ISO 639-1 language identifier. 96 | /// 97 | /// This type holds a string representing a language in the [ISO 693-1] format (two-letter code). 98 | /// The inner value is stored in a [`Cow`] to avoid allocation when possible. 99 | /// 100 | /// ## Validation 101 | /// The type inner value is not validated unless the [`validate`] method is used to initialize the instance. 102 | /// Generally, you should use this method to initialize this type. 103 | /// 104 | /// The performed validation only checks that the provided *looks like* an [ISO 693-1]language identifier 105 | /// (2 character alphanumeric ascii string). 106 | /// 107 | /// ## Serde support 108 | /// This type implements the `Serialize` and `Deserialize` traits if the `serde` feature is enabled. 109 | /// Deserialization will fail if the value is not an ISO 639-1 language identifier. 110 | /// 111 | /// ## Example 112 | /// ``` 113 | /// use rosetta_i18n::LanguageId; 114 | /// 115 | /// let language_id = LanguageId::new("fr"); 116 | /// assert_eq!(language_id.value(), "fr"); 117 | /// 118 | /// let language_id = LanguageId::validate("fr"); 119 | /// assert!(language_id.is_some()); 120 | /// ``` 121 | /// 122 | /// [ISO 693-1]: https://en.wikipedia.org/wiki/ISO_639-1 123 | /// [`validate`]: LanguageId::validate 124 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 125 | pub struct LanguageId<'a>(Cow<'a, str>); 126 | 127 | impl<'a> LanguageId<'a> { 128 | /// Initialize a new valid [`LanguageId`]. 129 | /// 130 | /// Unlike [`new`], this method ensures that the provided 131 | /// value is a valid [ISO 693-1] encoded language id. 132 | /// 133 | /// ``` 134 | /// # use rosetta_i18n::LanguageId; 135 | /// assert!(LanguageId::validate("fr").is_some()); 136 | /// assert!(LanguageId::validate("invalid").is_none()); 137 | /// ``` 138 | /// 139 | /// [`new`]: LanguageId::new 140 | /// [ISO 693-1]: https://en.wikipedia.org/wiki/ISO_639-1 141 | pub fn validate(value: &str) -> Option { 142 | let valid_length = value.len() == 2; 143 | let ascii_alphabetic = value.chars().all(|c| c.is_ascii_alphabetic()); 144 | 145 | if valid_length && ascii_alphabetic { 146 | Some(Self(Cow::Owned(value.to_ascii_lowercase()))) 147 | } else { 148 | None 149 | } 150 | } 151 | 152 | /// Initialize a new [`LanguageId`] from a string. 153 | /// 154 | /// The provided value must be an [ISO 693-1] encoded language id. 155 | /// If you want to validate the value, use [`validate`] instead. 156 | /// 157 | /// ``` 158 | /// # use rosetta_i18n::LanguageId; 159 | /// let language_id = LanguageId::new("en"); 160 | /// assert_eq!(language_id.value(), "en"); 161 | /// ``` 162 | /// 163 | /// [ISO 693-1]: https://en.wikipedia.org/wiki/ISO_639-1 164 | /// [`validate`]: LanguageId::validate 165 | pub fn new(value: impl Into>) -> Self { 166 | Self(value.into()) 167 | } 168 | 169 | /// Return a reference of the inner value. 170 | pub fn value(&self) -> &str { 171 | &self.0 172 | } 173 | 174 | /// Convert the type into a [`String`]. 175 | pub fn into_inner(self) -> String { 176 | self.0.into_owned() 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /rosetta-i18n/src/provider.rs: -------------------------------------------------------------------------------- 1 | //! Language data providers. 2 | //! 3 | //! This module contains types and traits for language data providers. 4 | //! Data providers are responsible of providing data to localize strings 5 | //! in given languages, such a plural rules. 6 | //! 7 | //! As Rosetta aims to be simple to use and maintain, we do not provide 8 | //! ready-to-use providers for every languages. We only provide a [`DefaultProvider`] 9 | //! which works for few common latin languages. However, you a free to implement 10 | //! providers for languages you need to support. 11 | //! 12 | //! ## Implementing a provider 13 | //! If you need to support extra languages that are not in the default provider, 14 | //! you should create a type that implement [`LanguageProvider`]. This type 15 | //! can then be used as a generic parameter of language type created by `rosetta-build`. 16 | //! 17 | //! Example implementation: 18 | //! ``` 19 | //! use rosetta_i18n::{ 20 | //! LanguageId, 21 | //! provider::{LanguageProvider, PluralCategory} 22 | //! }; 23 | //! 24 | //! /// A provider that only works for French. 25 | //! struct FrenchProvider; 26 | //! 27 | //! impl LanguageProvider for FrenchProvider { 28 | //! fn from_id(_language_id: &LanguageId) -> Self { 29 | //! Self 30 | //! } 31 | //! 32 | //! fn plural(&self, number: u64) -> PluralCategory { 33 | //! match number { 34 | //! 0 | 1 => PluralCategory::One, 35 | //! _ => PluralCategory::Other 36 | //! } 37 | //! } 38 | //! } 39 | //! ``` 40 | //! 41 | //! ## CLDR Data 42 | //! The reference source for locale data is [Unicode CLDR], an exhaustive dataset 43 | //! containing information about plural cases, number formatting and many more for 44 | //! most languages in the world. 45 | //! 46 | //! Many i18n Rust libraries such as `intl_pluralrules` bundle data from CLDR, and 47 | //! others like `icu4x` must be configured with an external CLDR data source. 48 | //! However, as the CLDR dataset is large and we only need a small subset of it, 49 | //! we did not choose to use it directly: most applications use only a few languages, 50 | //! so implementing a data provider manually is not much work. If you need to use the 51 | //! entire CLDR dataset, Rosetta might not be the good choice for you. 52 | //! 53 | //! If you need to implement a custom language provider, **it is strongly recommended to rely on 54 | //! CLDR data**. You can easily find this online (e.g. [plural rules]). 55 | //! 56 | //! [Unicode CLDR]: https://cldr.unicode.org/ 57 | //! [plural rules]: https://unicode-org.github.io/cldr-staging/charts/37/supplemental/language_plural_rules.html 58 | 59 | use crate::LanguageId; 60 | 61 | /// Trait for language data providers. 62 | /// 63 | /// This trait is implemented on types that provide data used 64 | /// to localize strings in a given language. See [`DefaultProvider`] 65 | /// for an example implementation. 66 | pub trait LanguageProvider: Sized { 67 | /// Initialize the provider from a language identifier. 68 | /// 69 | /// This method deliberately cannot fail. If a invalid 70 | /// value is provided, you should either return a default 71 | /// or a generic value. 72 | fn from_id(language_id: &LanguageId) -> Self; 73 | 74 | /// Select the appropriate [`PluralCategory`] for a given number. 75 | fn plural(&self, number: u64) -> PluralCategory; 76 | } 77 | 78 | /// CLDR Plural category. 79 | /// 80 | /// This type represent a plural category as defined in [Unicode CLDR Plural Rules]. 81 | /// 82 | /// [Unicode CLDR Plural Rules]: https://cldr.unicode.org/index/cldr-spec/plural-rules 83 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 84 | pub enum PluralCategory { 85 | /// Zero plural category. 86 | /// 87 | /// Used in Arabic, Latvian, and others. 88 | Zero, 89 | /// One plural category. 90 | /// 91 | /// Used for the singular form in many languages. 92 | One, 93 | /// Two plural category. 94 | /// 95 | /// Used in Arabic, Hebrew, Slovenian, and others. 96 | Two, 97 | /// Few plural category. 98 | /// 99 | /// Used in Romanian, Polish, Russian, and others. 100 | Few, 101 | /// Many plural category. 102 | /// 103 | /// Used in Polish, Russian, Ukrainian, and others. 104 | Many, 105 | /// Other plural category, used as a catch-all. 106 | /// 107 | /// In some languages, such as Japanese, Chinese, Korean, and Thai, this is the only plural category. 108 | Other, 109 | } 110 | 111 | /// Default built-in data provider. 112 | /// 113 | /// This type is a default provider implementation provided for 114 | /// simple use cases or testing purposes. **It only implements 115 | /// few common latin languages.** You should implement [`LanguageProvider`] 116 | /// yourself if you want to support more languages. 117 | /// 118 | /// The [`En`](DefaultProvider::En) variant is used when an unknown 119 | /// [`LanguageId`] is provided. 120 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 121 | pub enum DefaultProvider { 122 | /// English 123 | En, 124 | /// Spanish 125 | Es, 126 | /// French 127 | Fr, 128 | /// German 129 | De, 130 | /// Italian 131 | It, 132 | } 133 | 134 | impl LanguageProvider for DefaultProvider { 135 | fn from_id(language_id: &LanguageId) -> Self { 136 | match language_id.value() { 137 | "es" => Self::Es, 138 | "fr" => Self::Fr, 139 | "de" => Self::De, 140 | "it" => Self::It, 141 | _ => Self::En, 142 | } 143 | } 144 | 145 | fn plural(&self, number: u64) -> PluralCategory { 146 | match self { 147 | Self::En | Self::Es | Self::De | Self::It => match number { 148 | 1 => PluralCategory::One, 149 | _ => PluralCategory::Other, 150 | }, 151 | Self::Fr => match number { 152 | 0 | 1 => PluralCategory::One, 153 | _ => PluralCategory::Other, 154 | }, 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /rosetta-i18n/src/serde_helpers.rs: -------------------------------------------------------------------------------- 1 | //! Helpers to use serialize and deserialize types that implement [`Language`]. 2 | //! 3 | //! ## Provided helpers 4 | //! This module provide the [`as_language`] and [`as_language_with_fallback`] serde helpers. 5 | //! These helpers can be used to serialize and deserialize any type that implements [`Language`]. 6 | //! 7 | //! The [`as_language`] helper will produce an error when trying to deserialize an unsupported language, 8 | //! whereas the [`as_language_with_fallback`] helper will return the fallback value. 9 | //! 10 | //! ## Example 11 | //! ```rust 12 | //! # use serde::{Serialize, Deserialize}; 13 | //! use rosetta_i18n::{ 14 | //! GenericLanguage, 15 | //! serde_helpers::{as_language, as_language_with_fallback} 16 | //! }; 17 | //! 18 | //! #[derive(Serialize, Deserialize)] 19 | //! struct Config { 20 | //! #[serde(with = "as_language")] 21 | //! pub language: GenericLanguage, 22 | //! #[serde(with = "as_language_with_fallback")] 23 | //! pub language_fallback: GenericLanguage, 24 | //! } 25 | //! ``` 26 | //! 27 | //! [`Language`]: crate::Language 28 | 29 | use std::borrow::Cow; 30 | 31 | use serde::{de, ser}; 32 | 33 | use crate::LanguageId; 34 | 35 | impl ser::Serialize for LanguageId<'_> { 36 | fn serialize(&self, serializer: S) -> Result 37 | where 38 | S: ser::Serializer, 39 | { 40 | serializer.serialize_str(&self.0) 41 | } 42 | } 43 | 44 | impl<'de> de::Deserialize<'de> for LanguageId<'de> { 45 | fn deserialize(deserializer: D) -> Result 46 | where 47 | D: de::Deserializer<'de>, 48 | { 49 | let value = Cow::deserialize(deserializer)?; 50 | 51 | match Self::validate(&value) { 52 | Some(language_id) => Ok(language_id), 53 | None => Err(de::Error::custom(format!( 54 | "`{}` is not a valid ISO 693-1 language id", 55 | value 56 | ))), 57 | } 58 | } 59 | } 60 | 61 | pub mod as_language { 62 | //! Serialize and deserialize a type that implements [`Language`]. 63 | //! 64 | //! ## Example 65 | //! ```rust 66 | //! # use serde::{Serialize, Deserialize}; 67 | //! # use rosetta_i18n::serde_helpers::as_language; 68 | //! #[derive(Serialize, Deserialize)] 69 | //! struct Config { 70 | //! #[serde(with = "as_language")] 71 | //! pub language: rosetta_i18n::GenericLanguage 72 | //! } 73 | //! ``` 74 | 75 | use serde::{de, ser, Deserialize, Serialize}; 76 | 77 | use crate::{Language, LanguageId}; 78 | 79 | pub fn deserialize<'de, D, T>(deserializer: D) -> Result 80 | where 81 | D: de::Deserializer<'de>, 82 | T: Language, 83 | { 84 | let language_id = LanguageId::deserialize(deserializer)?; 85 | 86 | match T::from_language_id(&language_id) { 87 | Some(value) => Ok(value), 88 | None => Err(de::Error::custom("language `{}` is not supported")), 89 | } 90 | } 91 | 92 | pub fn serialize(val: &T, serializer: S) -> Result 93 | where 94 | S: ser::Serializer, 95 | T: Language, 96 | { 97 | val.language_id().serialize(serializer) 98 | } 99 | } 100 | 101 | pub mod as_language_with_fallback { 102 | //! Serialize and deserialize a type that implements [`Language`] with fallback value. 103 | //! 104 | //! If the language is unsupported, the fallback language is used. 105 | //! 106 | //! ## Example 107 | //! ```rust 108 | //! # use serde::{Serialize, Deserialize}; 109 | //! # use rosetta_i18n::serde_helpers::as_language_with_fallback; 110 | //! #[derive(Serialize, Deserialize)] 111 | //! struct Config { 112 | //! #[serde(with = "as_language_with_fallback")] 113 | //! pub language: rosetta_i18n::GenericLanguage 114 | //! } 115 | //! ``` 116 | 117 | use serde::{de, ser, Deserialize, Serialize}; 118 | 119 | use crate::{Language, LanguageId}; 120 | 121 | pub fn deserialize<'de, D, T>(deserializer: D) -> Result 122 | where 123 | D: de::Deserializer<'de>, 124 | T: Language, 125 | { 126 | let language_id = LanguageId::deserialize(deserializer)?; 127 | 128 | match T::from_language_id(&language_id) { 129 | Some(value) => Ok(value), 130 | None => Ok(T::fallback()), 131 | } 132 | } 133 | 134 | pub fn serialize(val: &T, serializer: S) -> Result 135 | where 136 | S: ser::Serializer, 137 | T: Language, 138 | { 139 | val.language_id().serialize(serializer) 140 | } 141 | } 142 | 143 | #[cfg(test)] 144 | mod tests { 145 | use serde::{Deserialize, Serialize}; 146 | use serde_test::{assert_de_tokens, assert_de_tokens_error, assert_tokens, Token}; 147 | 148 | use crate::Language; 149 | 150 | use super::{as_language, as_language_with_fallback, LanguageId}; 151 | 152 | #[test] 153 | fn serde_language_id() { 154 | let lang_id = LanguageId::new("en"); 155 | assert_tokens(&lang_id, &[Token::String("en")]); 156 | } 157 | 158 | #[test] 159 | fn serde_invalid_language_id() { 160 | assert_de_tokens_error::( 161 | &[Token::String("invalid")], 162 | "`invalid` is not a valid ISO 693-1 language id", 163 | ) 164 | } 165 | 166 | #[test] 167 | fn serde_as_language() { 168 | #[derive(Debug, PartialEq, Eq)] 169 | struct Lang; 170 | 171 | impl Language for Lang { 172 | fn from_language_id(language_id: &LanguageId) -> Option { 173 | match language_id.value() { 174 | "en" => Some(Self), 175 | _ => None, 176 | } 177 | } 178 | 179 | fn language_id(&self) -> LanguageId { 180 | LanguageId::new("en") 181 | } 182 | 183 | fn fallback() -> Self { 184 | unimplemented!() 185 | } 186 | } 187 | 188 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 189 | struct LanguageStruct { 190 | #[serde(with = "as_language")] 191 | lang: Lang, 192 | } 193 | 194 | let value = LanguageStruct { lang: Lang }; 195 | 196 | assert_tokens( 197 | &value, 198 | &[ 199 | Token::Struct { 200 | name: "LanguageStruct", 201 | len: 1, 202 | }, 203 | Token::Str("lang"), 204 | Token::String("en"), 205 | Token::StructEnd, 206 | ], 207 | ); 208 | } 209 | 210 | #[test] 211 | fn serde_as_language_fallback() { 212 | #[derive(Debug, PartialEq, Eq)] 213 | struct Lang; 214 | 215 | impl Language for Lang { 216 | fn from_language_id(language_id: &LanguageId) -> Option { 217 | match language_id.value() { 218 | "en" => Some(Self), 219 | _ => None, 220 | } 221 | } 222 | 223 | fn language_id(&self) -> LanguageId { 224 | LanguageId::new("en") 225 | } 226 | 227 | fn fallback() -> Self { 228 | Self 229 | } 230 | } 231 | 232 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 233 | struct LanguageStruct { 234 | #[serde(with = "as_language_with_fallback")] 235 | lang: Lang, 236 | } 237 | 238 | let value = LanguageStruct { lang: Lang }; 239 | 240 | assert_de_tokens( 241 | &value, 242 | &[ 243 | Token::Struct { 244 | name: "LanguageStruct", 245 | len: 1, 246 | }, 247 | Token::Str("lang"), 248 | Token::String("fr"), 249 | Token::StructEnd, 250 | ], 251 | ); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /rosetta-test/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rosetta-test" 3 | version = "0.1.3" 4 | edition = "2018" 5 | license = "ISC" 6 | publish = false 7 | 8 | [dependencies] 9 | rosetta-i18n = { path = "../rosetta-i18n" } 10 | static_assertions = "1.1" 11 | 12 | [build-dependencies] 13 | rosetta-build = { path = "../rosetta-build" } 14 | -------------------------------------------------------------------------------- /rosetta-test/build.rs: -------------------------------------------------------------------------------- 1 | fn main() -> Result<(), Box> { 2 | rosetta_build::config() 3 | .source("fr", "locales/fr.json") 4 | .source("en", "locales/en.json") 5 | .fallback("en") 6 | .generate()?; 7 | 8 | Ok(()) 9 | } 10 | -------------------------------------------------------------------------------- /rosetta-test/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "Hello world!", 3 | "hello_name": "Hello {name}!", 4 | "display_age": "{name} is {age} years old.", 5 | "fallback_key": "This key does not exist in fr.json" 6 | } 7 | -------------------------------------------------------------------------------- /rosetta-test/locales/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "Bonjour le monde !", 3 | "hello_name": "Bonjour {name} !", 4 | "display_age": "{name} a {age} ans." 5 | } 6 | -------------------------------------------------------------------------------- /rosetta-test/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Tests for rosetta i18n library 2 | //! 3 | //! This crate is only used to test code generated by rosetta-build 4 | //! and does not expose anything useful. 5 | 6 | #[cfg(test)] 7 | mod tests { 8 | use std::{fmt::Debug, hash::Hash}; 9 | 10 | use rosetta_i18n::{Language, LanguageId}; 11 | use static_assertions::assert_impl_all; 12 | 13 | rosetta_i18n::include_translations!(); 14 | 15 | assert_impl_all!( 16 | Lang: Language, 17 | Debug, 18 | Clone, 19 | Copy, 20 | Eq, 21 | PartialEq, 22 | Hash, 23 | Send, 24 | Sync 25 | ); 26 | 27 | #[test] 28 | fn test_simple() { 29 | assert_eq!(Lang::En.hello(), "Hello world!"); 30 | assert_eq!(Lang::Fr.hello(), "Bonjour le monde !"); 31 | } 32 | 33 | #[test] 34 | fn test_formatted() { 35 | assert_eq!(Lang::En.hello_name("John"), "Hello John!"); 36 | assert_eq!(Lang::Fr.hello_name("John"), "Bonjour John !"); 37 | } 38 | 39 | #[test] 40 | fn test_formatted_multiple() { 41 | assert_eq!(Lang::En.display_age(30, "John"), "John is 30 years old."); 42 | assert_eq!(Lang::Fr.display_age(30, "John"), "John a 30 ans."); 43 | } 44 | 45 | #[test] 46 | fn test_fallback() { 47 | assert_eq!(Lang::Fr.fallback_key(), Lang::En.fallback_key()); 48 | assert_eq!(Lang::fallback(), Lang::En); 49 | } 50 | 51 | #[test] 52 | fn test_from_language_id() { 53 | let en = LanguageId::new("en"); 54 | let fr = LanguageId::new("fr"); 55 | let de = LanguageId::new("de"); 56 | 57 | assert_eq!(Lang::from_language_id(&en), Some(Lang::En)); 58 | assert_eq!(Lang::from_language_id(&fr), Some(Lang::Fr)); 59 | assert_eq!(Lang::from_language_id(&de), None); 60 | } 61 | 62 | #[test] 63 | fn test_to_language_id() { 64 | assert_eq!(Lang::En.language_id().value(), "en"); 65 | assert_eq!(Lang::Fr.language_id().value(), "fr"); 66 | } 67 | } 68 | --------------------------------------------------------------------------------