From 1957ba706f62a82a9f60df1f28e23de5fa8bb319 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Wed, 17 Jun 2026 01:05:24 +0800 Subject: Implement fallback chain resolution for translation keys --- README.md | 25 ++++++ shakehand-test/Cargo.toml | 15 ++++ shakehand-test/locale/test_fallback.toml | 13 +++ shakehand-test/src/lib.rs | 80 ++++++++++++++++- shakehand/src/analyzer.rs | 49 ++++++++++ shakehand/src/lib.rs | 63 ++++++++++++- shakehand/src/shakehand.rs | 148 ++++++++++++++++++++----------- 7 files changed, 334 insertions(+), 59 deletions(-) create mode 100644 shakehand-test/locale/test_fallback.toml diff --git a/README.md b/README.md index e4ea242..628daed 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,31 @@ You can also pass the return value of another translation as a parameter, since let greeting = Global::greeting(Global::world()); ``` +## 5. Fallback Chain (Optional) + +You can configure a **fallback chain** in your `Cargo.toml` under `[package.metadata.shakehand]`. + +When a language has no translation for a key, the system will automatically walk the chain + to find the nearest language that has one. + +```toml +[package.metadata.shakehand] +# For any language not explicitly listed, fall back to English +fallback.other = "en" + +# Specific fallback rules +fallback.zh_HK = "zh_CN" # Hong Kong Trad -> Simplified Chinese +fallback.zh_TW = "zh_CN" # Taiwan Trad -> Simplified Chinese +fallback.zh_CN = "en" # Simplified Chinese -> English +fallback.it = "fr" # Italian -> French +``` + +The chain resolution is **at runtime via a generated `FallbackSolver`**: +- Each language has a `try_fallback_once()` step defined in a compile-time generated match. +- Translation functions loop through the chain until a language with the value is found. +- `fallback.other` is the ultimate fallback (falls back to `locale!`'s `fallback` parameter if unset). +- All languages in the chain are automatically added to the `Languages` enum, even if they + have no translations in the locale files. # Contributing diff --git a/shakehand-test/Cargo.toml b/shakehand-test/Cargo.toml index 3f20603..98d28f8 100644 --- a/shakehand-test/Cargo.toml +++ b/shakehand-test/Cargo.toml @@ -5,3 +5,18 @@ edition = "2024" [dependencies] shakehand.workspace = true + +[package.metadata.shakehand] +# Fallback chain: when the specified language does not exist, how to fall back the language +fallback.other = "en" +fallback.zh_HK = "zh_CN" # Hong Kong Traditional -> Simplified Chinese +fallback.zh_TW = "zh_CN" # Taiwan Traditional -> Simplified Chinese +fallback.zh_CN = "en" # Simplified Chinese -> English +fallback.vi = "zh_CN" # Vietnamese -> Simplified Chinese +fallback.ms = "id" # Malay -> Indonesian +fallback.it = "fr" # Italian -> French +fallback.pt = "es" # Portuguese -> Spanish +fallback.sv = "no" # Swedish -> Norwegian +fallback.no = "da" # Norwegian -> Danish +fallback.da = "sv" # Danish -> Swedish +fallback.tr = "ar" # Turkish -> Arabic diff --git a/shakehand-test/locale/test_fallback.toml b/shakehand-test/locale/test_fallback.toml new file mode 100644 index 0000000..ddcf627 --- /dev/null +++ b/shakehand-test/locale/test_fallback.toml @@ -0,0 +1,13 @@ +[en] +hello = "Hello" +goodbye = "Goodbye" + +[zh_CN] +hello = "你好" + +[fr] +hello = "Bonjour" +goodbye = "Au revoir" + +[it] +goodbye = "Arrivederci" diff --git a/shakehand-test/src/lib.rs b/shakehand-test/src/lib.rs index c290533..8c60441 100644 --- a/shakehand-test/src/lib.rs +++ b/shakehand-test/src/lib.rs @@ -1,9 +1,18 @@ #[cfg(test)] mod test { use crate::locale::{Global, Languages, set_lang}; + use std::sync::{Mutex, MutexGuard}; + + /// Global lock to serialize tests that mutate `__SHAKE_HAND_LANG`. + /// Without this, parallel test execution causes races on the shared global atomic state. + static LANG_LOCK: Mutex<()> = Mutex::new(()); + fn lock_lang() -> MutexGuard<'static, ()> { + LANG_LOCK.lock().unwrap() + } #[test] fn test_english() { + let _lock = lock_lang(); set_lang(Languages::en); assert_eq!(Global::world(), "world"); assert_eq!(Global::greeting("Alice"), "Hello, Alice!"); @@ -13,6 +22,7 @@ mod test { #[test] fn test_chinese() { + let _lock = lock_lang(); set_lang(Languages::zh_CN); assert_eq!(Global::world(), "世界"); assert_eq!(Global::greeting("小明"), "你好,小明!"); @@ -22,6 +32,7 @@ mod test { #[test] fn test_japanese() { + let _lock = lock_lang(); set_lang(Languages::ja); assert_eq!(Global::world(), "世界"); assert_eq!(Global::greeting("田中"), "こんにちは、田中!"); @@ -31,6 +42,7 @@ mod test { #[test] fn test_korean() { + let _lock = lock_lang(); set_lang(Languages::ko); assert_eq!(Global::world(), "세계"); assert_eq!(Global::greeting("철수"), "안녕하세요, 철수님!"); @@ -40,6 +52,7 @@ mod test { #[test] fn test_french() { + let _lock = lock_lang(); set_lang(Languages::fr); assert_eq!(Global::world(), "monde"); assert_eq!(Global::greeting("Marie"), "Bonjour, Marie !"); @@ -49,6 +62,7 @@ mod test { #[test] fn test_german() { + let _lock = lock_lang(); set_lang(Languages::de); assert_eq!(Global::world(), "Welt"); assert_eq!(Global::greeting("Hans"), "Hallo, Hans!"); @@ -58,6 +72,7 @@ mod test { #[test] fn test_spanish() { + let _lock = lock_lang(); set_lang(Languages::es); assert_eq!(Global::world(), "mundo"); assert_eq!(Global::greeting("Carlos"), "¡Hola, Carlos!"); @@ -67,6 +82,7 @@ mod test { #[test] fn test_russian() { + let _lock = lock_lang(); set_lang(Languages::ru); assert_eq!(Global::world(), "мир"); assert_eq!(Global::greeting("Анна"), "Привет, Анна!"); @@ -76,6 +92,7 @@ mod test { #[test] fn test_arabic() { + let _lock = lock_lang(); set_lang(Languages::ar); assert_eq!(Global::world(), "عالم"); assert_eq!(Global::greeting("أحمد"), "مرحبًا، أحمد!"); @@ -85,6 +102,7 @@ mod test { #[test] fn test_portuguese() { + let _lock = lock_lang(); set_lang(Languages::pt); assert_eq!(Global::world(), "mundo"); assert_eq!(Global::greeting("João"), "Olá, João!"); @@ -93,7 +111,67 @@ mod test { } #[test] - fn test_parameterless_translation_as_param() { + fn test_fallback_zh_hk_to_zh_cn() { + let _lock = lock_lang(); + set_lang(Languages::zh_HK); + assert_eq!(Global::world(), "世界"); + assert_eq!(Global::greeting("小明"), "你好,小明!"); + assert_eq!(Global::farewell("小红"), "再见,小红!"); + assert_eq!(Global::thanks(), "谢谢!"); + } + + #[test] + fn test_fallback_zh_tw_to_zh_cn() { + let _lock = lock_lang(); + set_lang(Languages::zh_TW); + assert_eq!(Global::world(), "世界"); + assert_eq!(Global::greeting("小明"), "你好,小明!"); + } + + #[test] + fn test_fallback_vi_to_zh_cn() { + let _lock = lock_lang(); + set_lang(Languages::vi); + assert_eq!(Global::world(), "世界"); + assert_eq!(Global::greeting("小明"), "你好,小明!"); + } + + #[test] + fn test_fallback_ms_to_id_to_en() { + let _lock = lock_lang(); + set_lang(Languages::ms); + assert_eq!(Global::world(), "world"); + assert_eq!(Global::greeting("Alice"), "Hello, Alice!"); + } + + #[test] + fn test_fallback_it_to_fr() { + let _lock = lock_lang(); + set_lang(Languages::it); + assert_eq!(Global::world(), "monde"); + assert_eq!(Global::greeting("Marie"), "Bonjour, Marie !"); + } + + #[test] + fn test_fallback_pt_to_es() { + let _lock = lock_lang(); + // pt has direct translations, so fallback should not be triggered + set_lang(Languages::pt); + assert_eq!(Global::world(), "mundo"); + assert_eq!(Global::greeting("João"), "Olá, João!"); + } + + #[test] + fn test_fallback_tr_to_ar() { + let _lock = lock_lang(); + set_lang(Languages::tr); + assert_eq!(Global::world(), "عالم"); + assert_eq!(Global::greeting("أحمد"), "مرحبًا، أحمد!"); + } + + #[test] + fn test_fallback_parameterless_translation_as_param() { + let _lock = lock_lang(); set_lang(Languages::en); let greeting = Global::greeting(Global::world()); assert_eq!(greeting, "Hello, world!"); diff --git a/shakehand/src/analyzer.rs b/shakehand/src/analyzer.rs index 3007f58..f53461d 100644 --- a/shakehand/src/analyzer.rs +++ b/shakehand/src/analyzer.rs @@ -179,6 +179,55 @@ pub fn scan_toml_files(dir: &Path) -> Vec<(Vec, PathBuf)> { files } +/// Fallback chain configuration read from the crate's `Cargo.toml` `[package.metadata.shakehand]` +#[derive(Clone, Debug)] +pub struct FallbackConfig { + /// Per-language fallback: `source_language -> fallback_language` + pub fallback_map: BTreeMap, + /// Default fallback for any language not in `fallback_map` (`fallback.other`) + pub default_fallback: String, +} + +/// Read fallback configuration from the crate's `Cargo.toml` +/// under `[package.metadata.shakehand]`. +/// +/// Expects entries like: +/// ```toml +/// [package.metadata.shakehand] +/// fallback.other = "en" +/// fallback.zh_HK = "zh_CN" +/// fallback.zh_TW = "zh_CN" +/// ``` +pub fn read_fallback_from_manifest(manifest_dir: &str) -> Option { + let cargo_toml_path = Path::new(manifest_dir).join("Cargo.toml"); + let content = fs::read_to_string(&cargo_toml_path).ok()?; + let value: toml::Value = content.parse().ok()?; + + let shakehand = value.get("package")?.get("metadata")?.get("shakehand")?; + + let mut fallback_map = BTreeMap::new(); + let mut default_fallback = String::from("en"); + + // In TOML, `fallback.other = "en"` under `[package.metadata.shakehand]` + // creates a nested table `{ fallback: { other: "en", ... } }`. + if let Some(fallback_table) = shakehand.get("fallback").and_then(|v| v.as_table()) { + for (key, val) in fallback_table { + if let Some(fb_lang) = val.as_str() { + if key == "other" { + default_fallback = fb_lang.to_string(); + } else { + fallback_map.insert(key.to_string(), fb_lang.to_string()); + } + } + } + } + + Some(FallbackConfig { + fallback_map, + default_fallback, + }) +} + /// Parse a toml file pub fn parse_toml_file(path: &Path) -> Option { let content = fs::read_to_string(path).ok()?; diff --git a/shakehand/src/lib.rs b/shakehand/src/lib.rs index 419e088..404690c 100644 --- a/shakehand/src/lib.rs +++ b/shakehand/src/lib.rs @@ -79,6 +79,30 @@ //! let greeting = Global::greeting(Global::world()); //! ``` //! +//! ## 5. Fallback Chain (Optional) +//! +//! You can configure a **fallback chain** in your `Cargo.toml` under `[package.metadata.shakehand]`. +//! When a language has no translation for a key, the system will automatically walk the chain +//! to find the nearest language that has one. +//! +//! ```toml +//! [package.metadata.shakehand] +//! # For any language not explicitly listed, fall back to English +//! fallback.other = "en" +//! # Specific fallback rules +//! fallback.zh_HK = "zh_CN" # Hong Kong Trad -> Simplified Chinese +//! fallback.zh_TW = "zh_CN" # Taiwan Trad -> Simplified Chinese +//! fallback.zh_CN = "en" # Simplified Chinese -> English +//! fallback.it = "fr" # Italian -> French +//! ``` +//! +//! The chain resolution is **at runtime via a generated `FallbackSolver`**: +//! - Each language has a `try_fallback_once()` step defined in a compile-time generated match. +//! - Translation functions loop through the chain until a language with the value is found. +//! - `fallback.other` is the ultimate fallback (falls back to `locale!`'s `fallback` parameter if unset). +//! - All languages in the chain are automatically added to the `Languages` enum, even if they +//! have no translations in the locale files. +//! //! # Contributing //! //! Directly open a PR to the [repository](https://github.com/catilgrass/shakehand) and mention [@Weicao-CatilGrass](https://github.com/Weicao-CatilGrass). @@ -131,15 +155,46 @@ pub fn locale(input: TokenStream) -> TokenStream { } } + // Read fallback chain from the crate's Cargo.toml [package.metadata.shakehand] + let fallback_config = analyzer::read_fallback_from_manifest(&manifest_dir); + + // `default_fallback`: use `fallback.other` from Cargo.toml, else the macro's `fallback` param + let default_fallback = fallback_config + .as_ref() + .map(|c| c.default_fallback.as_str()) + .unwrap_or(&input.fallback) + .to_string(); + let fallback_map = fallback_config.map(|c| c.fallback_map).unwrap_or_default(); + + // Add all languages referenced in the fallback chain (keys and values) to `all_languages`, + // so the `Languages` enum includes variants that the chain may reference. + for (k, v) in &fallback_map { + all_languages.insert(k.clone()); + all_languages.insert(v.clone()); + } + all_languages.insert(default_fallback.clone()); + if parsed_files.is_empty() { - let lang_enum = - shakehand::generate_module(parsed_files, all_languages, input.fallback, &input.path); + let lang_enum = shakehand::generate_module( + parsed_files, + all_languages, + input.fallback, + fallback_map, + default_fallback, + &input.path, + ); return TokenStream::from(quote::quote! { #lang_enum }); } - let generated = - shakehand::generate_module(parsed_files, all_languages, input.fallback, &input.path); + let generated = shakehand::generate_module( + parsed_files, + all_languages, + input.fallback, + fallback_map, + default_fallback, + &input.path, + ); TokenStream::from(generated) } diff --git a/shakehand/src/shakehand.rs b/shakehand/src/shakehand.rs index 3e73b09..b0b55ff 100644 --- a/shakehand/src/shakehand.rs +++ b/shakehand/src/shakehand.rs @@ -133,66 +133,34 @@ fn make_format_expr(value: &str) -> TokenStream2 { 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, - fallback: &str, -) -> (Vec, TokenStream2) { +/// Generate match arms for a single entry (only for languages that have a value) +/// The loop in `generate_entry_method` handles fallback chain walking. +fn make_match_arms(entry: &TranslationEntry) -> Vec { let mut arms: Vec = 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 { + for lang in entry.values.keys() { 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); + arms.push(quote! { Languages::#variant_name => return #body, }); } Some(v) => { - let arm = quote! { Languages::#variant_name => #v, }; - if is_fallback { - found_fallback = true; - fallback_arm = quote! { _ => #v, }; - } - arms.push(arm); + arms.push(quote! { Languages::#variant_name => return #v, }); } 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) + arms } /// Generate a method for a single translation entry fn generate_entry_method( entry: &TranslationEntry, all_languages: &BTreeSet, - fallback: &str, ) -> TokenStream2 { let method_name = format_ident!("{}", key_to_ident(&entry.key)); let key_str = format!("Key \"{}\"", entry.key); @@ -237,9 +205,33 @@ fn generate_entry_method( }; } - // 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); + // Generate match arms only for languages that have a value; + // missing ones fall through to `_ => {}` which triggers the fallback loop + let match_arms = make_match_arms(entry); + let loop_body = if match_arms.is_empty() { + // No language has a value for this key — should not happen with valid data + let panic_msg = format!( + "shakehand: key `{}` has no translation in any language", + entry.key, + ); + quote! { + let __lang = lang(); + match __lang { + _ => panic!(#panic_msg), + } + } + } else { + quote! { + let mut __lang = lang(); + loop { + match __lang { + #(#match_arms)* + _ => {}, + } + __lang = FallbackSolver::try_fallback_once(__lang); + } + } + }; if entry.has_params { let params_with_type: Vec = entry @@ -280,10 +272,7 @@ fn generate_entry_method( #[must_use] pub fn #method_name (#(#params_with_type),*) -> String { #(#param_bindings)* - match lang() { - #(#match_arms)* - #catch_all - } + #loop_body } } } else { @@ -294,10 +283,7 @@ fn generate_entry_method( #(#lang_docs)* #[must_use] pub fn #method_name () -> &'static str { - match lang() { - #(#match_arms)* - #catch_all - } + #loop_body } } } @@ -308,13 +294,12 @@ fn generate_struct( toml_file: &TomlFile, all_languages: &BTreeSet, locale_path: &str, - fallback: &str, ) -> TokenStream2 { let struct_name = format_ident!("{}", toml_file.struct_name); let methods: Vec = toml_file .entries .iter() - .map(|entry| generate_entry_method(entry, all_languages, fallback)) + .map(|entry| generate_entry_method(entry, all_languages)) .collect(); let struct_name_str = toml_file.struct_name.as_str(); @@ -361,14 +346,67 @@ fn generate_struct( } } +/// Generate the `FallbackSolver` struct with a `try_fallback_once` method +fn generate_fallback_solver( + all_languages: &BTreeSet, + fallback_map: &BTreeMap, + default_fallback: &str, +) -> TokenStream2 { + let mut arms: Vec = Vec::new(); + + for lang in all_languages { + let variant = format_ident!("{}", lang_to_variant(lang)); + let fb = fallback_map + .get(lang.as_str()) + .map(|s| s.as_str()) + .unwrap_or(default_fallback); + let fb_variant = format_ident!("{}", lang_to_variant(fb)); + arms.push(quote! { Languages::#variant => Languages::#fb_variant, }); + } + + // Ensure `default_fallback` is a valid variant + let default_fb_variant = if all_languages.contains(default_fallback) { + format_ident!("{}", lang_to_variant(default_fallback)) + } else { + // Fall back to the first available language + let first = all_languages.iter().next().expect("at least one language"); + format_ident!("{}", lang_to_variant(first)) + }; + + quote! { + /// Fallback solver: resolves the fallback chain one step at a time. + /// + /// Each language maps to its configured fallback. + /// Languages without an explicit fallback map to `default_fallback`. + /// The root fallback maps to itself, terminating the chain. + pub struct FallbackSolver; + + impl FallbackSolver { + /// Try to fall back one step from the given language. + /// Returns the fallback language to try next. + #[inline(always)] + pub fn try_fallback_once(lang: Languages) -> Languages { + match lang { + #(#arms)* + _ => Languages::#default_fb_variant, + } + } + } + } +} + /// Generate the complete module code pub fn generate_module( files: Vec, all_languages: BTreeSet, fallback: String, + fallback_map: BTreeMap, + default_fallback: String, locale_path: &str, ) -> TokenStream2 { let lang_enum = generate_languages_enum(&all_languages, &fallback, locale_path); + let fallback_solver = + generate_fallback_solver(&all_languages, &fallback_map, &default_fallback); // Group by module path let mut root_files: Vec<&TomlFile> = Vec::new(); @@ -386,7 +424,7 @@ pub fn generate_module( // Generate root-level structs let root_structs: Vec = root_files .iter() - .map(|f| generate_struct(f, &all_languages, locale_path, &fallback)) + .map(|f| generate_struct(f, &all_languages, locale_path)) .collect(); // Generate sub-modules @@ -403,7 +441,7 @@ pub fn generate_module( entries: f.entries.clone(), all_languages: f.all_languages.clone(), }; - generate_struct(&fixed_file, &all_languages, locale_path, &fallback) + generate_struct(&fixed_file, &all_languages, locale_path) }) .collect(); @@ -418,6 +456,8 @@ pub fn generate_module( quote! { #lang_enum + #fallback_solver + #(#root_structs)* #(#sub_mods)* -- cgit