diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-06-17 00:14:14 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-06-17 00:38:17 +0800 |
| commit | 83d91396bc84a0a35b5b676ab8c2518678a1dd3f (patch) | |
| tree | c3cca7c0faa6b4750993379ff9d6fee3d866ee82 /shakehand | |
Restructure workspace into subcrates0.1.0
Diffstat (limited to 'shakehand')
| -rw-r--r-- | shakehand/Cargo.toml | 20 | ||||
| -rw-r--r-- | shakehand/src/analyzer.rs | 261 | ||||
| -rw-r--r-- | shakehand/src/lib.rs | 145 | ||||
| -rw-r--r-- | shakehand/src/shakehand.rs | 425 | ||||
| -rw-r--r-- | shakehand/test-locale/global.toml | 7 |
5 files changed, 858 insertions, 0 deletions
diff --git a/shakehand/Cargo.toml b/shakehand/Cargo.toml new file mode 100644 index 0000000..2a0bb9e --- /dev/null +++ b/shakehand/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "shakehand" +version.workspace = true +edition.workspace = true +description = "A rust i18n lib - Let different languages shake hands with each other!" +license = "MIT OR Apache-2.0" +repository.workspace = true +homepage.workspace = true +keywords = ["internationalization", "i18n", "localization", "translation", "compile-time"] +categories = ["internationalization", "localization"] + +[lib] +proc-macro = true + +[dependencies] +syn = { workspace = true } +quote = { workspace = true } +proc-macro2 = { workspace = true } +just_fmt = { workspace = true } +toml = { workspace = true } diff --git a/shakehand/src/analyzer.rs b/shakehand/src/analyzer.rs new file mode 100644 index 0000000..3007f58 --- /dev/null +++ b/shakehand/src/analyzer.rs @@ -0,0 +1,261 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use syn::{ + Ident, LitStr, Token, + parse::{Parse, ParseStream}, +}; + +/// Macro input: `shakehand::locale!("../i18n/", fallback = "en")` +pub struct ShakehandInput { + pub path: String, + pub fallback: String, +} + +impl Parse for ShakehandInput { + fn parse(input: ParseStream) -> syn::Result<Self> { + let path_lit: LitStr = input.parse()?; + let path = path_lit.value(); + + let mut fallback = String::from("en"); + + if input.peek(Token![,]) { + let _: Token![,] = input.parse()?; + if input.peek(Ident) { + let ident: Ident = input.parse()?; + if ident == "fallback" { + let _: Token![=] = input.parse()?; + let fb: LitStr = input.parse()?; + fallback = fb.value(); + } + } + } + + Ok(ShakehandInput { path, fallback }) + } +} + +/// A translation entry +#[derive(Clone)] +pub struct TranslationEntry { + /// All keys + pub key: String, + + /// All values + pub values: BTreeMap<String, String>, + + /// Whether parameters exist, affects the function signature + pub has_params: bool, + + /// Canonical parameter name list, valid when there is no conflict + /// + /// **Order**: taken from the first language that has parameters + pub params: Vec<String>, + + /// Whether the entry has a conflict + /// + /// Conflicts arise in the following cases: + /// - Inconsistent parameter counts + /// - Inconsistent parameter names + pub params_conflict: bool, +} + +/// The parsing result of a toml file +#[derive(Clone)] +pub struct TomlFile { + pub module_path: Vec<String>, + pub struct_name: String, + pub entries: Vec<TranslationEntry>, + pub all_languages: BTreeSet<String>, +} + +/// Convert a key to a valid Rust identifier +pub fn key_to_ident(key: &str) -> String { + let snake = just_fmt::snake_case!(key); + if snake.starts_with(|c: char| c.is_ascii_digit()) { + format!("a{}", snake) + } else if syn::parse_str::<Ident>(&snake).is_ok() { + snake + } else { + format!("a_{}", snake) + } +} + +/// Generate a struct name (PascalCase) from a filename (without extension) +pub fn filename_to_struct_name(filename: &str) -> String { + just_fmt::pascal_case!(filename) +} + +/// Generate a module name (snake_case) from a path +pub fn path_to_mod_name(filename: &str) -> String { + just_fmt::snake_case!(filename) +} + +/// Convert a language key (e.g. `"en"`, `"zh_CN"`, `"en-US"`) to a valid enum variant name +/// Rules: `-` → `_`, preserve case, prepend `_` if starts with a digit +pub fn lang_to_variant(lang: &str) -> String { + let raw = lang.replace('-', "_"); + if raw.starts_with(|c: char| c.is_ascii_digit()) { + format!("_{}", raw) + } else { + raw + } +} + +/// Extract `%{param}` parameters from a string +pub fn extract_params(s: &str) -> Vec<String> { + let mut params = Vec::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' && chars.peek() == Some(&'{') { + chars.next(); + let mut param = String::new(); + while let Some(&next) = chars.peek() { + if next == '}' { + chars.next(); + break; + } + param.push(next); + chars.next(); + } + if !param.is_empty() && !params.contains(¶m) { + params.push(param); + } + } + } + params +} + +/// Replace `%{param}` with `{}`, preserving occurrence order for `format!` +pub fn replace_params_with_format(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' && chars.peek() == Some(&'{') { + chars.next(); + while let Some(&next) = chars.peek() { + if next == '}' { + chars.next(); + break; + } + chars.next(); + } + result.push_str("{}"); + } else { + result.push(c); + } + } + result +} + +/// Recursively scan a directory to collect all `.toml` files with their module paths +pub fn scan_toml_files(dir: &Path) -> Vec<(Vec<String>, PathBuf)> { + let mut files = Vec::new(); + + if !dir.exists() { + return files; + } + + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let sub_files = scan_toml_files(&path); + let dir_name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + for (mut mod_path, file_path) in sub_files { + mod_path.insert(0, dir_name.clone()); + files.push((mod_path, file_path)); + } + } else if path.extension().is_some_and(|ext| ext == "toml") { + files.push((vec![], path)); + } + } + } + + files +} + +/// Parse a toml file +pub fn parse_toml_file(path: &Path) -> Option<TomlFile> { + let content = fs::read_to_string(path).ok()?; + let value: toml::Value = content.parse().ok()?; + + let table = value.as_table()?; + + let mut raw_entries: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new(); + let mut all_languages: BTreeSet<String> = BTreeSet::new(); + let mut all_keys: BTreeSet<String> = BTreeSet::new(); + + for (lang_key, lang_val) in table { + all_languages.insert(lang_key.clone()); + if let Some(lang_table) = lang_val.as_table() { + for (entry_key, entry_val) in lang_table { + all_keys.insert(entry_key.clone()); + if let Some(s) = entry_val.as_str() { + raw_entries + .entry(entry_key.clone()) + .or_default() + .insert(lang_key.clone(), s.to_string()); + } + } + } + } + + let mut entries = Vec::new(); + for key in &all_keys { + let values = raw_entries.get(key).cloned().unwrap_or_default(); + + // Collect parameter list for each language (in occurrence order) + let lang_params: Vec<Vec<String>> = values.values().map(|v| extract_params(v)).collect(); + + // Convert to parameter name sets for comparison (names only, not order) + let param_sets: Vec<BTreeSet<String>> = lang_params + .iter() + .map(|p| p.iter().cloned().collect()) + .collect(); + + // Conflict: inconsistent parameter name sets across languages (count or names differ) + let params_conflict = if param_sets.is_empty() { + false + } else { + let first = ¶m_sets[0]; + !param_sets.iter().skip(1).all(|p| p == first) + }; + + // Take parameter order from the first language that has parameters as canonical; empty params on conflict + let (has_params, params) = if params_conflict { + (false, vec![]) + } else { + let canonical = values + .values() + .find_map(|v| { + let p = extract_params(v); + if !p.is_empty() { Some(p) } else { None } + }) + .unwrap_or_default(); + (!canonical.is_empty(), canonical) + }; + + entries.push(TranslationEntry { + key: key.clone(), + values, + has_params, + params, + params_conflict, + }); + } + + let filename = path.file_stem()?.to_string_lossy().to_string(); + let struct_name = filename_to_struct_name(&filename); + + Some(TomlFile { + module_path: vec![], + struct_name, + entries, + all_languages, + }) +} diff --git a/shakehand/src/lib.rs b/shakehand/src/lib.rs new file mode 100644 index 0000000..419e088 --- /dev/null +++ b/shakehand/src/lib.rs @@ -0,0 +1,145 @@ +//! "Shake Hand!" +//! +//! > Let different languages shake hands with each other! 🤝 +//! +//! This is a **purely compile-time** Rust internationalization library. **All** localized strings are fully embedded into the binary at compile time, with only minimal runtime overhead. +//! +//! # How to Use? +//! +//! ## 1. Add Dependency +//! +//! Add the following to your `Cargo.toml`: +//! +//! ```toml +//! shakehand = "0.1" +//! ``` +//! +//! ## 2. Write Configuration Files +//! +//! Create a directory under your project to serve as the root for translations, e.g. `./locale/`. +//! Create any `toml` file inside this directory, e.g. `./locale/global.toml`, representing a translation file. +//! +//! In the file, group translations by language section. The key is the translation key, and the value is the translated text. +//! Text with parameters uses `%{parameter_name}` as placeholders, where the parameter name directly maps to the generated function's parameter name: +//! +//! ```toml +//! [en] +//! world = "world" +//! greeting = "Hello, %{someone}!" +//! +//! [zh_CN] +//! world = "世界" +//! greeting = "你好,%{someone}!" +//! ``` +//! +//! ## 3. Load Translations +//! +//! In your Rust code, use `shakehand::locale!` to hardcode the entire directory into a module: +//! +//! ```rust +//! pub mod translation { +//! shakehand::locale!("./test-locale", fallback = "en"); +//! } +//! ``` +//! +//! The generated module will contain: +//! - A `Languages` enum listing all languages +//! - A `set_lang` function for switching the current language +//! - A unit struct `Global` (named after `global.toml`), whose associated functions are the translations for each key +//! +//! ## 4. Call Translations +//! +//! Call translations just like ordinary functions, passing parameters by placeholder name: +//! +//! ```rust +//! use crate::translation::{Global, Languages, set_lang}; +//! +//! fn main() { +//! set_lang(Languages::en); +//! let greeting = Global::greeting("World"); +//! println!("{}", greeting); // Hello, World! +//! +//! set_lang(Languages::zh_CN); +//! let greeting = Global::greeting("世界"); +//! println!("{}", greeting); // 你好,世界! +//! } +//! +//! # pub mod translation { +//! # shakehand::locale!("./test-locale", fallback = "en"); +//! # } +//! ``` +//! +//! You can also pass the return value of another translation as a parameter, since each parameterless translation function returns a `&'static str`, completely allocation-free: +//! +//! ```rust +//! # use shakehand::locale; +//! # use a::Global; +//! # pub mod a { shakehand::locale!("./test-locale"); }; +//! # a::set_lang(a::Languages::en); +//! let greeting = Global::greeting(Global::world()); +//! ``` +//! +//! # Contributing +//! +//! Directly open a PR to the [repository](https://github.com/catilgrass/shakehand) and mention [@Weicao-CatilGrass](https://github.com/Weicao-CatilGrass). +//! +//! # License +//! +//! MIT or Apache 2.0 + +use std::path::Path; + +use proc_macro::TokenStream; +use syn::parse_macro_input; + +mod analyzer; +mod shakehand; + +/// `locale!` macro: Generate an i18n module from toml files at compile time +/// +/// # Usage +/// +/// ``` +/// pub mod my_i18n { +/// shakehand::locale!("./test-locale/", fallback = "en"); +/// } +/// ``` +/// +/// # Parameters +/// +/// - `path` - i18n directory path (relative to `Cargo.toml`) +/// - `fallback` - optional, default language (defaults to `"en"`) +#[proc_macro] +pub fn locale(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as analyzer::ShakehandInput); + + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); + + let base_path = Path::new(&manifest_dir).join(&input.path); + let base_path = just_fmt::fmt_path::fmt_path(&base_path).unwrap_or_else(|_| base_path.clone()); + + let scanned_files = analyzer::scan_toml_files(&base_path); + + let mut parsed_files = Vec::new(); + let mut all_languages: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); + + for (mod_path, file_path) in &scanned_files { + if let Some(mut toml_file) = analyzer::parse_toml_file(file_path) { + toml_file.module_path = mod_path.clone(); + all_languages.extend(toml_file.all_languages.clone()); + parsed_files.push(toml_file); + } + } + + if parsed_files.is_empty() { + let lang_enum = + shakehand::generate_module(parsed_files, all_languages, input.fallback, &input.path); + return TokenStream::from(quote::quote! { + #lang_enum + }); + } + + let generated = + shakehand::generate_module(parsed_files, all_languages, input.fallback, &input.path); + TokenStream::from(generated) +} diff --git a/shakehand/src/shakehand.rs b/shakehand/src/shakehand.rs new file mode 100644 index 0000000..3e73b09 --- /dev/null +++ b/shakehand/src/shakehand.rs @@ -0,0 +1,425 @@ +use proc_macro2::{Ident, TokenStream as TokenStream2}; +use quote::{format_ident, quote}; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::analyzer::{ + TomlFile, TranslationEntry, extract_params, key_to_ident, lang_to_variant, path_to_mod_name, + replace_params_with_format, +}; + +/// Generate the language enum (and functions `lang()` / `set_lang()`) +fn generate_languages_enum( + all_languages: &BTreeSet<String>, + fallback: &str, + locale_path: &str, +) -> TokenStream2 { + let variants_info: Vec<(Ident, String)> = all_languages + .iter() + .map(|lang| { + let name = lang_to_variant(lang); + (format_ident!("{}", name), lang.clone()) + }) + .collect(); + + let enum_doc = format!("All language files present under \"{}\"", locale_path); + + if variants_info.is_empty() { + return quote! { + /// This constant stores the discriminant of the current language variant. + /// It is initialized at program start by reading the locale or a config file. + pub static __SHAKE_HAND_LANG: std::sync::atomic::AtomicU8 = + std::sync::atomic::AtomicU8::new(0u8); + + #[derive(Debug, Default, Clone, Copy)] + #[repr(u8)] + #[doc = #enum_doc] + pub enum Languages {} + + #[inline(always)] + /// Get the current language from the global static variable + pub fn lang() -> Languages { + panic!("shakehand: no locale files found") + } + + #[inline(always)] + /// Set the current language in the global static variable + pub fn set_lang(_lang: Languages) { + panic!("shakehand: no locale files found") + } + }; + } + + let fallback_idx = all_languages + .iter() + .position(|l| l == fallback) + .unwrap_or(0); + + let fallback_ident = &variants_info[fallback_idx].0; + + // List of enum variants with doc comments, fallback variant gets #[default] + let enum_variants: Vec<TokenStream2> = variants_info + .iter() + .enumerate() + .map(|(i, (ident, raw))| { + if i == fallback_idx { + quote! { + #[doc = #raw] + #[default] + #ident, + } + } else { + quote! { + #[doc = #raw] + #ident, + } + } + }) + .collect(); + + // lang() match arms (match u8 values as returned by AtomicU8::load) + + let lang_match_arms: Vec<TokenStream2> = variants_info + .iter() + .enumerate() + .map(|(i, (ident, _))| { + let idx = i as u8; + quote! { #idx => Languages::#ident, } + }) + .collect(); + + quote! { + /// This constant stores the discriminant of the current language variant. + /// It is initialized at program start by reading the locale or a config file. + pub static __SHAKE_HAND_LANG: std::sync::atomic::AtomicU8 = + std::sync::atomic::AtomicU8::new(#fallback_idx as u8); + + #[derive(Debug, Default, Clone, Copy)] + #[repr(u8)] + #[allow(non_camel_case_types)] + #[doc = #enum_doc] + pub enum Languages { + #(#enum_variants)* + } + + /// Get the current language from the global static variable + #[inline(always)] + pub fn lang() -> Languages { + match __SHAKE_HAND_LANG.load(std::sync::atomic::Ordering::Relaxed) { + #(#lang_match_arms)* + _ => Languages::#fallback_ident, + } + } + + /// Set the current language in the global static variable + #[inline(always)] + pub fn set_lang(lang: Languages) { + __SHAKE_HAND_LANG.store(lang as u8, std::sync::atomic::Ordering::Relaxed); + } + } +} + +/// Generate a `format!(fmt_str, args…)` expression for a value that has parameters +fn make_format_expr(value: &str) -> TokenStream2 { + let fmt_str = replace_params_with_format(value); + let lang_params = extract_params(value); + let format_args: Vec<TokenStream2> = lang_params + .iter() + .map(|p| { + let var = format_ident!("{}", just_fmt::snake_case!(p)); + // 取出 .as_ref() 后的变量值 + quote! { #var } + }) + .collect(); + quote! { format!(#fmt_str, #(#format_args),*) } +} + +/// Generate match arms for a single entry (arms for languages with values) and a `_ =>` catch-all (fallback) +fn make_match_arms( + entry: &TranslationEntry, + all_available: &BTreeSet<String>, + fallback: &str, +) -> (Vec<TokenStream2>, TokenStream2) { + let mut arms: Vec<TokenStream2> = Vec::new(); + let mut found_fallback = false; + + let mut fallback_arm = if entry.has_params { + quote! { _ => ::std::string::String::new(), } + } else { + quote! { _ => "", } + }; + + for lang in all_available { + let value = entry.values.get(lang.as_str()); + let variant_name = format_ident!("{}", lang_to_variant(lang)); + let is_fallback = lang == fallback; + + match value { + Some(v) if entry.has_params => { + let body = make_format_expr(v); + let arm = quote! { Languages::#variant_name => #body, }; + if is_fallback { + found_fallback = true; + fallback_arm = quote! { _ => #body, }; + } + arms.push(arm); + } + Some(v) => { + let arm = quote! { Languages::#variant_name => #v, }; + if is_fallback { + found_fallback = true; + fallback_arm = quote! { _ => #v, }; + } + arms.push(arm); + } + None => {} + } + } + + // When the fallback language doesn't have a value for this key, use the first available language as a catch-all + if !found_fallback && let Some(first_val) = entry.values.values().next() { + if entry.has_params { + let body = make_format_expr(first_val); + fallback_arm = quote! { _ => #body, }; + } else { + fallback_arm = quote! { _ => #first_val, }; + } + } + + (arms, fallback_arm) +} + +/// Generate a method for a single translation entry +fn generate_entry_method( + entry: &TranslationEntry, + all_languages: &BTreeSet<String>, + fallback: &str, +) -> TokenStream2 { + let method_name = format_ident!("{}", key_to_ident(&entry.key)); + let key_str = format!("Key \"{}\"", entry.key); + + // Doc table showing each language's value for this key + let mut lang_rows: Vec<TokenStream2> = Vec::new(); + // Table header + lang_rows.push(quote! { #[doc = "|Language|Value|"] }); + lang_rows.push(quote! { #[doc = "|-|-|"] }); + for lang in all_languages.iter() { + let val = entry + .values + .get(lang.as_str()) + .map(|s| s.as_str()) + .unwrap_or("(NO TRANSLATION)"); + let row = format!("|**{}**|*\"{}\"*|", lang, val); + lang_rows.push(quote! { #[doc = #row] }); + } + let lang_docs = lang_rows; + + // Parameter name conflict: compile error + deprecated function + if entry.params_conflict { + let err_msg = format!( + "shakehand: key `{}` has inconsistent parameter names across languages", + entry.key, + ); + let panic_msg = format!( + "shakehand: key `{}` has inconsistent parameter names across languages, fix the .toml file", + entry.key, + ); + return quote! { + ::core::compile_error!(#err_msg); + + #[deprecated(note = "parameter mismatch across languages, fix the .toml file")] + #[doc = #key_str] + /// + #(#lang_docs)* + #[must_use] + pub fn #method_name () -> ! { + panic!(#panic_msg) + } + }; + } + + // Only generate match arms for languages that have a translation; missing ones fall through to `_ =>` + let (match_arms, catch_all) = + make_match_arms(entry, &entry.values.keys().cloned().collect(), fallback); + + if entry.has_params { + let params_with_type: Vec<TokenStream2> = entry + .params + .iter() + .map(|p| { + let name = format_ident!("{}", just_fmt::snake_case!(p)); + quote! { #name: impl AsRef<str> } + }) + .collect(); + + let param_bindings: Vec<TokenStream2> = entry + .params + .iter() + .map(|p| { + let name = format_ident!("{}", just_fmt::snake_case!(p)); + quote! { let #name = #name.as_ref(); } + }) + .collect(); + + let param_docs: Vec<TokenStream2> = entry + .params + .iter() + .map(|p| { + let doc = format!("- `{}`", p); + quote! { #[doc = #doc] } + }) + .collect(); + + quote! { + #[inline(always)] + #[doc = #key_str] + /// + #(#lang_docs)* + /// + /// # Parameters + #(#param_docs)* + #[must_use] + pub fn #method_name (#(#params_with_type),*) -> String { + #(#param_bindings)* + match lang() { + #(#match_arms)* + #catch_all + } + } + } + } else { + quote! { + #[inline(always)] + #[doc = #key_str] + /// + #(#lang_docs)* + #[must_use] + pub fn #method_name () -> &'static str { + match lang() { + #(#match_arms)* + #catch_all + } + } + } + } +} + +/// Generate a struct and its impl block for a single toml file +fn generate_struct( + toml_file: &TomlFile, + all_languages: &BTreeSet<String>, + locale_path: &str, + fallback: &str, +) -> TokenStream2 { + let struct_name = format_ident!("{}", toml_file.struct_name); + let methods: Vec<TokenStream2> = toml_file + .entries + .iter() + .map(|entry| generate_entry_method(entry, all_languages, fallback)) + .collect(); + + let struct_name_str = toml_file.struct_name.as_str(); + + // Count how many keys each language has, for the table + let mut lang_counts: Vec<(String, usize)> = all_languages + .iter() + .map(|lang| { + let count = toml_file + .entries + .iter() + .filter(|e| e.values.contains_key(lang.as_str())) + .count(); + (lang.clone(), count) + }) + .collect(); + lang_counts.sort_by(|a, b| a.1.cmp(&b.1).reverse()); + + // Table rows + let mut count_rows: Vec<TokenStream2> = Vec::new(); + count_rows.push(quote! { #[doc = "|Language|Count|"] }); + count_rows.push(quote! { #[doc = "|-|-|"] }); + for (lang, count) in &lang_counts { + let row = format!("|**{}**|{}|", lang, count); + count_rows.push(quote! { #[doc = #row] }); + } + + let path_doc = format!( + "Language information from file `{}/{}.toml`", + locale_path, struct_name_str + ); + + quote! { + #[doc = concat!("# ", #struct_name_str)] + /// + #[doc = #path_doc] + /// + #(#count_rows)* + pub struct #struct_name; + + impl #struct_name { + #(#methods)* + } + } +} + +/// Generate the complete module code +pub fn generate_module( + files: Vec<TomlFile>, + all_languages: BTreeSet<String>, + fallback: String, + locale_path: &str, +) -> TokenStream2 { + let lang_enum = generate_languages_enum(&all_languages, &fallback, locale_path); + + // Group by module path + let mut root_files: Vec<&TomlFile> = Vec::new(); + let mut sub_modules: BTreeMap<String, Vec<&TomlFile>> = BTreeMap::new(); + + for f in &files { + if f.module_path.is_empty() { + root_files.push(f); + } else { + let mod_name = f.module_path[0].clone(); + sub_modules.entry(mod_name).or_default().push(f); + } + } + + // Generate root-level structs + let root_structs: Vec<TokenStream2> = root_files + .iter() + .map(|f| generate_struct(f, &all_languages, locale_path, &fallback)) + .collect(); + + // Generate sub-modules + let sub_mods: Vec<TokenStream2> = sub_modules + .iter() + .map(|(mod_name, mod_files): (&String, &Vec<&TomlFile>)| { + let mod_ident = format_ident!("{}", path_to_mod_name(mod_name)); + let sub_structs: Vec<TokenStream2> = mod_files + .iter() + .map(|f| { + let fixed_file = TomlFile { + module_path: f.module_path[1..].to_vec(), + struct_name: f.struct_name.clone(), + entries: f.entries.clone(), + all_languages: f.all_languages.clone(), + }; + generate_struct(&fixed_file, &all_languages, locale_path, &fallback) + }) + .collect(); + + quote! { + pub mod #mod_ident { + #(#sub_structs)* + } + } + }) + .collect(); + + quote! { + #lang_enum + + #(#root_structs)* + + #(#sub_mods)* + } +} diff --git a/shakehand/test-locale/global.toml b/shakehand/test-locale/global.toml new file mode 100644 index 0000000..0fde1bd --- /dev/null +++ b/shakehand/test-locale/global.toml @@ -0,0 +1,7 @@ +[en] +world = "world" +greeting = "Hello, %{someone}!" + +[zh_CN] +world = "世界" +gretting = "你好,%{someone}!" |
