aboutsummaryrefslogtreecommitdiff
path: root/shakehand
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-06-17 00:14:14 +0800
committer魏曹先生 <1992414357@qq.com>2026-06-17 00:38:17 +0800
commit83d91396bc84a0a35b5b676ab8c2518678a1dd3f (patch)
treec3cca7c0faa6b4750993379ff9d6fee3d866ee82 /shakehand
Restructure workspace into subcrates0.1.0
Diffstat (limited to 'shakehand')
-rw-r--r--shakehand/Cargo.toml20
-rw-r--r--shakehand/src/analyzer.rs261
-rw-r--r--shakehand/src/lib.rs145
-rw-r--r--shakehand/src/shakehand.rs425
-rw-r--r--shakehand/test-locale/global.toml7
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(&param) {
+ 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 = &param_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}!"