From 096cab0461cbcb366eea9f1e0442870786d2557b Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 10 Aug 2025 11:19:20 +0200 Subject: [PATCH] clap: improve translation support --- src/bin/coreutils.rs | 22 +- src/uucore/src/lib/mods/clap_localization.rs | 74 +- src/uucore/src/lib/mods/locale.rs | 880 +++++++++---------- 3 files changed, 442 insertions(+), 534 deletions(-) diff --git a/src/bin/coreutils.rs b/src/bin/coreutils.rs index c6d2283bd..64a79a3fd 100644 --- a/src/bin/coreutils.rs +++ b/src/bin/coreutils.rs @@ -81,18 +81,16 @@ fn find_prefixed_util<'a>( } fn setup_localization_or_exit(util_name: &str) { - locale::setup_localization_with_common(get_canonical_util_name(util_name)).unwrap_or_else( - |err| { - match err { - uucore::locale::LocalizationError::ParseResource { - error: err_msg, - snippet, - } => eprintln!("Localization parse error at {snippet}: {err_msg}"), - other => eprintln!("Could not init the localization system: {other}"), - } - process::exit(99) - }, - ); + locale::setup_localization(get_canonical_util_name(util_name)).unwrap_or_else(|err| { + match err { + uucore::locale::LocalizationError::ParseResource { + error: err_msg, + snippet, + } => eprintln!("Localization parse error at {snippet}: {err_msg}"), + other => eprintln!("Could not init the localization system: {other}"), + } + process::exit(99) + }); } #[allow(clippy::cognitive_complexity)] diff --git a/src/uucore/src/lib/mods/clap_localization.rs b/src/uucore/src/lib/mods/clap_localization.rs index 8ef624dd1..11784cf53 100644 --- a/src/uucore/src/lib/mods/clap_localization.rs +++ b/src/uucore/src/lib/mods/clap_localization.rs @@ -89,9 +89,8 @@ pub fn display_usage_and_help(util_name: &str) { } pub fn handle_clap_error_with_exit_code(err: Error, util_name: &str, exit_code: i32) -> ! { - // Try to ensure localization is initialized for this utility - // If it's already initialized, that's fine - we'll use the existing one - let _ = crate::locale::setup_localization_with_common(util_name); + // Ensure localization is initialized for this utility (always with common strings) + let _ = crate::locale::setup_localization(util_name); // Check if colors are enabled by examining clap's rendered output let rendered_str = err.render().to_string(); @@ -115,7 +114,7 @@ pub fn handle_clap_error_with_exit_code(err: Error, util_name: &str, exit_code: } ErrorKind::UnknownArgument => { // Force localization initialization - ignore any previous failures - crate::locale::setup_localization_with_common(util_name).ok(); + crate::locale::setup_localization(util_name).ok(); // Choose exit code based on utility name let exit_code = match util_name { @@ -134,34 +133,17 @@ pub fn handle_clap_error_with_exit_code(err: Error, util_name: &str, exit_code: let arg_str = invalid_arg.to_string(); // Get localized error word with fallback - let error_word = { - let translated = translate!("common-error"); - if translated == "common-error" { - "error".to_string() - } else { - translated - } - }; + let error_word = translate!("common-error"); let colored_arg = maybe_colorize(&arg_str, Color::Yellow); let colored_error_word = maybe_colorize(&error_word, Color::Red); // Print main error message with fallback - let error_msg = { - let translated = translate!( - "clap-error-unexpected-argument", - "arg" => colored_arg.clone(), - "error_word" => colored_error_word.clone() - ); - if translated.starts_with("clap-error-unexpected-argument") { - format!( - "{}: unexpected argument '{}' found", - colored_error_word, colored_arg - ) - } else { - translated - } - }; + let error_msg = translate!( + "clap-error-unexpected-argument", + "arg" => colored_arg.clone(), + "error_word" => colored_error_word.clone() + ); eprintln!("{error_msg}"); eprintln!(); @@ -172,21 +154,11 @@ pub fn handle_clap_error_with_exit_code(err: Error, util_name: &str, exit_code: let colored_tip_word = maybe_colorize(&tip_word, Color::Green); let colored_suggestion = maybe_colorize(&suggested_arg.to_string(), Color::Green); - let suggestion_msg = { - let translated = translate!( - "clap-error-similar-argument", - "tip_word" => colored_tip_word.clone(), - "suggestion" => colored_suggestion.clone() - ); - if translated.starts_with("clap-error-similar-argument") { - format!( - " {}: a similar argument exists: '{}'", - colored_tip_word, colored_suggestion - ) - } else { - format!(" {}", translated) - } - }; + let suggestion_msg = translate!( + "clap-error-similar-argument", + "tip_word" => colored_tip_word.clone(), + "suggestion" => colored_suggestion.clone() + ); eprintln!("{suggestion_msg}"); eprintln!(); } else { @@ -204,14 +176,7 @@ pub fn handle_clap_error_with_exit_code(err: Error, util_name: &str, exit_code: let usage_key = format!("{util_name}-usage"); let usage_text = translate!(&usage_key); let formatted_usage = crate::format_usage(&usage_text); - let usage_label = { - let translated = translate!("common-usage"); - if translated == "common-usage" { - "Usage".to_string() - } else { - translated - } - }; + let usage_label = translate!("common-usage"); eprintln!("{}: {}", usage_label, formatted_usage); eprintln!(); eprintln!("For more information, try '--help'."); @@ -219,14 +184,7 @@ pub fn handle_clap_error_with_exit_code(err: Error, util_name: &str, exit_code: std::process::exit(exit_code); } else { // Generic fallback case - let error_word = { - let translated = translate!("common-error"); - if translated == "common-error" { - "error".to_string() - } else { - translated - } - }; + let error_word = translate!("common-error"); let colored_error_word = maybe_colorize(&error_word, Color::Red); eprintln!("{colored_error_word}: unexpected argument"); std::process::exit(exit_code); diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 8e1a09a23..c8e735403 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -107,46 +107,6 @@ thread_local! { static LOCALIZER: OnceLock = const { OnceLock::new() }; } -/// Initialize localization with a specific locale and config -fn init_localization( - locale: &LanguageIdentifier, - locales_dir: &Path, - util_name: &str, -) -> Result<(), LocalizationError> { - let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE) - .expect("Default locale should always be valid"); - - // Try to load English from embedded resources first, then fall back to filesystem. - // This ensures consistent behavior and faster loading since embedded resources - // are immediately available. The filesystem fallback allows for development - // and testing scenarios where locale files might be present in the filesystem. - let english_bundle = - create_english_bundle_from_embedded(&default_locale, util_name).or_else(|_| { - // Try filesystem as fallback (useful for development/testing) - create_bundle(&default_locale, locales_dir) - })?; - - let loc = if locale == &default_locale { - // If requesting English, just use English as primary (no fallback needed) - Localizer::new(english_bundle) - } else { - // Try to load the requested locale - if let Ok(primary_bundle) = create_bundle(locale, locales_dir) { - // Successfully loaded requested locale, load English as fallback - Localizer::new(primary_bundle).with_fallback(english_bundle) - } else { - // Failed to load requested locale, just use English as primary - Localizer::new(english_bundle) - } - }; - - LOCALIZER.with(|lock| { - lock.set(loc) - .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into())) - })?; - Ok(()) -} - /// Helper function to find the uucore locales directory from a utility's locales directory fn find_uucore_locales_dir(utility_locales_dir: &Path) -> Option { // Normalize the path to get absolute path @@ -167,7 +127,7 @@ fn find_uucore_locales_dir(utility_locales_dir: &Path) -> Option { } /// Create a bundle that combines common and utility-specific strings -fn create_bundle_with_common( +fn create_bundle( locale: &LanguageIdentifier, locales_dir: &Path, util_name: &str, @@ -209,7 +169,7 @@ fn create_bundle_with_common( } /// Initialize localization with common strings in addition to utility-specific strings -fn init_localization_with_common( +fn init_localization( locale: &LanguageIdentifier, locales_dir: &Path, util_name: &str, @@ -218,18 +178,17 @@ fn init_localization_with_common( .expect("Default locale should always be valid"); // Try to create a bundle that combines common and utility-specific strings - let english_bundle = create_bundle_with_common(&default_locale, locales_dir, util_name) - .or_else(|_| { - // Fallback to embedded utility-specific strings only - create_english_bundle_from_embedded(&default_locale, util_name) - })?; + let english_bundle = create_bundle(&default_locale, locales_dir, util_name).or_else(|_| { + // Fallback to embedded utility-specific and common strings + create_english_bundle_from_embedded(&default_locale, util_name) + })?; let loc = if locale == &default_locale { // If requesting English, just use English as primary (no fallback needed) Localizer::new(english_bundle) } else { // Try to load the requested locale with common strings - if let Ok(primary_bundle) = create_bundle_with_common(locale, locales_dir, util_name) { + if let Ok(primary_bundle) = create_bundle(locale, locales_dir, util_name) { // Successfully loaded requested locale, load English as fallback Localizer::new(primary_bundle).with_fallback(english_bundle) } else { @@ -245,53 +204,29 @@ fn init_localization_with_common( Ok(()) } -/// Create a bundle for a specific locale -fn create_bundle( - locale: &LanguageIdentifier, - locales_dir: &Path, -) -> Result, LocalizationError> { - let locale_path = locales_dir.join(format!("{locale}.ftl")); - - let ftl_file = fs::read_to_string(&locale_path).map_err(|e| LocalizationError::Io { - source: e, - path: locale_path.clone(), - })?; - - let resource = FluentResource::try_new(ftl_file.clone()).map_err( - |(_partial_resource, mut errs): (FluentResource, Vec)| { - let first_err = errs.remove(0); - // Attempt to extract the snippet from the original ftl_file - let snippet = if let Some(range) = first_err.slice.clone() { - ftl_file.get(range).unwrap_or("").to_string() +/// Helper function to parse FluentResource from content string +fn parse_fluent_resource(content: &str) -> Result { + FluentResource::try_new(content.to_string()).map_err( + |(_partial_resource, errs): (FluentResource, Vec)| { + if let Some(first_err) = errs.into_iter().next() { + let snippet = first_err + .slice + .clone() + .and_then(|range| content.get(range)) + .unwrap_or("") + .to_string(); + LocalizationError::ParseResource { + error: first_err, + snippet, + } } else { - String::new() - }; - LocalizationError::ParseResource { - error: first_err, - snippet, + LocalizationError::LocalesDirNotFound("Parse error without details".to_string()) } }, - )?; - - let mut bundle = FluentBundle::new(vec![locale.clone()]); - - // Disable Unicode directional isolate characters (U+2068, U+2069) - // By default, Fluent wraps variables for security - // and proper text rendering in mixed-script environments (Arabic + English). - // Disabling gives cleaner output: "Welcome, Alice!" but reduces protection - // against bidirectional text attacks. Safe for English-only applications. - bundle.set_use_isolating(false); - - bundle.add_resource(resource).map_err(|errs| { - LocalizationError::Bundle(format!( - "Failed to add resource to bundle for {locale}: {errs:?}", - )) - })?; - - Ok(bundle) + ) } -/// Create a bundle from embedded English locale files +/// Create a bundle from embedded English locale files with common uucore strings fn create_english_bundle_from_embedded( locale: &LanguageIdentifier, util_name: &str, @@ -304,41 +239,31 @@ fn create_english_bundle_from_embedded( } let embedded_locales = get_embedded_locales(); - let locale_key = format!("{util_name}/en-US.ftl"); - - let ftl_content = embedded_locales.get(locale_key.as_str()).ok_or_else(|| { - LocalizationError::LocalesDirNotFound(format!("No embedded locale found for {util_name}")) - })?; - - let resource = FluentResource::try_new(ftl_content.to_string()).map_err( - |(_partial_resource, errs): (FluentResource, Vec)| { - if let Some(first_err) = errs.into_iter().next() { - let snippet = first_err - .slice - .clone() - .and_then(|range| ftl_content.get(range)) - .unwrap_or("") - .to_string(); - LocalizationError::ParseResource { - error: first_err, - snippet, - } - } else { - LocalizationError::LocalesDirNotFound("Parse error without details".to_string()) - } - }, - )?; - let mut bundle = FluentBundle::new(vec![locale.clone()]); bundle.set_use_isolating(false); - bundle.add_resource(resource).map_err(|errs| { - LocalizationError::Bundle(format!( - "Failed to add embedded resource to bundle for {locale}: {errs:?}", - )) - })?; + // First, try to load common uucore strings + let uucore_key = "uucore/en-US.ftl"; + if let Some(uucore_content) = embedded_locales.get(uucore_key) { + let uucore_resource = parse_fluent_resource(uucore_content)?; + bundle.add_resource_overriding(uucore_resource); + } - Ok(bundle) + // Then, try to load utility-specific strings + let locale_key = format!("{util_name}/en-US.ftl"); + if let Some(ftl_content) = embedded_locales.get(locale_key.as_str()) { + let resource = parse_fluent_resource(ftl_content)?; + bundle.add_resource_overriding(resource); + } + + // Return the bundle if we have either common strings or utility-specific strings + if bundle.has_message("common-error") || bundle.has_message(&format!("{util_name}-about")) { + Ok(bundle) + } else { + Err(LocalizationError::LocalesDirNotFound(format!( + "No embedded locale found for {util_name} and no common strings found" + ))) + } } fn get_message_internal(id: &str, args: Option) -> String { @@ -426,6 +351,7 @@ fn detect_system_locale() -> Result { } /// Sets up localization using the system locale with English fallback. +/// Always loads common strings in addition to utility-specific strings. /// /// This function initializes the localization system based on the system's locale /// preferences (via the LANG environment variable) or falls back to English @@ -466,42 +392,14 @@ pub fn setup_localization(p: &str) -> Result<(), LocalizationError> { LanguageIdentifier::from_str(DEFAULT_LOCALE).expect("Default locale should always be valid") }); - // Try to find the locales directory. If found, use init_localization which - // will prioritize embedded resources but can also load from filesystem. - // If no locales directory exists, directly use embedded English resources. - match get_locales_dir(p) { - Ok(locales_dir) => init_localization(&locale, &locales_dir, p), - Err(_) => { - // No locales directory found, use embedded English directly - let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE) - .expect("Default locale should always be valid"); - let english_bundle = create_english_bundle_from_embedded(&default_locale, p)?; - let localizer = Localizer::new(english_bundle); - - LOCALIZER.with(|lock| { - lock.set(localizer) - .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into())) - })?; - Ok(()) - } - } -} - -/// Enhanced version of setup_localization that also loads common/clap error strings -/// This function loads both utility-specific strings and common strings for clap error handling -pub fn setup_localization_with_common(p: &str) -> Result<(), LocalizationError> { - let locale = detect_system_locale().unwrap_or_else(|_| { - LanguageIdentifier::from_str(DEFAULT_LOCALE).expect("Default locale should always be valid") - }); - // Load common strings along with utility-specific strings match get_locales_dir(p) { Ok(locales_dir) => { // Load both utility-specific and common strings - init_localization_with_common(&locale, &locales_dir, p) + init_localization(&locale, &locales_dir, p) } Err(_) => { - // No locales directory found, use embedded English directly + // No locales directory found, use embedded English with common strings directly let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE) .expect("Default locale should always be valid"); let english_bundle = create_english_bundle_from_embedded(&default_locale, p)?; @@ -664,6 +562,62 @@ mod tests { use std::path::PathBuf; use tempfile::TempDir; + /// Test-specific helper function to create a bundle from test directory only + #[cfg(test)] + fn create_test_bundle( + locale: &LanguageIdentifier, + test_locales_dir: &Path, + ) -> Result, LocalizationError> { + let mut bundle = FluentBundle::new(vec![locale.clone()]); + bundle.set_use_isolating(false); + + // Only load from the test directory - no common strings or utility-specific paths + let locale_path = test_locales_dir.join(format!("{locale}.ftl")); + if let Ok(ftl_content) = fs::read_to_string(&locale_path) { + let resource = parse_fluent_resource(&ftl_content)?; + bundle.add_resource_overriding(resource); + return Ok(bundle); + } + + Err(LocalizationError::LocalesDirNotFound(format!( + "No localization strings found for {locale} in {}", + test_locales_dir.display() + ))) + } + + /// Test-specific initialization function for test directories + #[cfg(test)] + fn init_test_localization( + locale: &LanguageIdentifier, + test_locales_dir: &Path, + ) -> Result<(), LocalizationError> { + let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE) + .expect("Default locale should always be valid"); + + // Create English bundle from test directory + let english_bundle = create_test_bundle(&default_locale, test_locales_dir)?; + + let loc = if locale == &default_locale { + // If requesting English, just use English as primary + Localizer::new(english_bundle) + } else { + // Try to load the requested locale from test directory + if let Ok(primary_bundle) = create_test_bundle(locale, test_locales_dir) { + // Successfully loaded requested locale, load English as fallback + Localizer::new(primary_bundle).with_fallback(english_bundle) + } else { + // Failed to load requested locale, just use English as primary + Localizer::new(english_bundle) + } + }; + + LOCALIZER.with(|lock| { + lock.set(loc) + .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into())) + })?; + Ok(()) + } + /// Helper function to create a temporary directory with test locale files fn create_test_locales_dir() -> TempDir { let temp_dir = TempDir::new().expect("Failed to create temp directory"); @@ -729,31 +683,12 @@ invalid-syntax = This is { $missing temp_dir } - #[test] - fn test_localization_error_from_io_error() { - let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found"); - let loc_error = LocalizationError::from(io_error); - - match loc_error { - LocalizationError::Io { source: _, path } => { - assert_eq!(path, PathBuf::from("")); - } - _ => panic!("Expected IO error variant"), - } - } - - #[test] - fn test_localization_error_uerror_impl() { - let error = LocalizationError::Bundle("some error".to_string()); - assert_eq!(error.code(), 1); - } - #[test] fn test_create_bundle_success() { let temp_dir = create_test_locales_dir(); let locale = LanguageIdentifier::from_str("en-US").unwrap(); - let result = create_bundle(&locale, temp_dir.path()); + let result = create_test_bundle(&locale, temp_dir.path()); assert!(result.is_ok()); let bundle = result.unwrap(); @@ -765,13 +700,13 @@ invalid-syntax = This is { $missing let temp_dir = TempDir::new().unwrap(); let locale = LanguageIdentifier::from_str("de-DE").unwrap(); - let result = create_bundle(&locale, temp_dir.path()); + let result = create_test_bundle(&locale, temp_dir.path()); assert!(result.is_err()); - if let Err(LocalizationError::Io { source: _, path }) = result { - assert!(path.to_string_lossy().contains("de-DE.ftl")); + if let Err(LocalizationError::LocalesDirNotFound(_)) = result { + // Expected - no localization strings found } else { - panic!("Expected IO error"); + panic!("Expected LocalesDirNotFound error"); } } @@ -780,24 +715,29 @@ invalid-syntax = This is { $missing let temp_dir = create_test_locales_dir(); let locale = LanguageIdentifier::from_str("es-ES").unwrap(); - let result = create_bundle(&locale, temp_dir.path()); - assert!(result.is_err()); + let result = create_test_bundle(&locale, temp_dir.path()); - if let Err(LocalizationError::ParseResource { - error: _parser_err, - snippet: _, - }) = result - { - // Expected ParseResource variant - } else { - panic!("Expected ParseResource error"); + // The result should be an error due to invalid syntax + match result { + Err(LocalizationError::ParseResource { + error: _parser_err, + snippet: _, + }) => { + // Expected ParseResource variant - test passes + } + Ok(_) => { + panic!("Expected ParseResource error, but bundle was created successfully"); + } + Err(other) => { + panic!("Expected ParseResource error, but got: {other:?}"); + } } } #[test] fn test_localizer_format_primary_bundle() { let temp_dir = create_test_locales_dir(); - let en_bundle = create_bundle( + let en_bundle = create_test_bundle( &LanguageIdentifier::from_str("en-US").unwrap(), temp_dir.path(), ) @@ -812,7 +752,7 @@ invalid-syntax = This is { $missing fn test_localizer_format_with_args() { use fluent::FluentArgs; let temp_dir = create_test_locales_dir(); - let en_bundle = create_bundle( + let en_bundle = create_test_bundle( &LanguageIdentifier::from_str("en-US").unwrap(), temp_dir.path(), ) @@ -829,12 +769,12 @@ invalid-syntax = This is { $missing #[test] fn test_localizer_fallback_to_english() { let temp_dir = create_test_locales_dir(); - let fr_bundle = create_bundle( + let fr_bundle = create_test_bundle( &LanguageIdentifier::from_str("fr-FR").unwrap(), temp_dir.path(), ) .unwrap(); - let en_bundle = create_bundle( + let en_bundle = create_test_bundle( &LanguageIdentifier::from_str("en-US").unwrap(), temp_dir.path(), ) @@ -854,7 +794,7 @@ invalid-syntax = This is { $missing #[test] fn test_localizer_format_message_not_found() { let temp_dir = create_test_locales_dir(); - let en_bundle = create_bundle( + let en_bundle = create_test_bundle( &LanguageIdentifier::from_str("en-US").unwrap(), temp_dir.path(), ) @@ -872,10 +812,7 @@ invalid-syntax = This is { $missing let temp_dir = create_test_locales_dir(); let locale = LanguageIdentifier::from_str("en-US").unwrap(); - let result = init_localization(&locale, temp_dir.path(), "nonexistent_test_util"); - if let Err(e) = &result { - eprintln!("Init localization failed: {e}"); - } + let result = init_test_localization(&locale, temp_dir.path()); assert!(result.is_ok()); // Test that we can get messages @@ -892,7 +829,7 @@ invalid-syntax = This is { $missing let temp_dir = create_test_locales_dir(); let locale = LanguageIdentifier::from_str("fr-FR").unwrap(); - let result = init_localization(&locale, temp_dir.path(), "nonexistent_test_util"); + let result = init_test_localization(&locale, temp_dir.path()); assert!(result.is_ok()); // Test French message @@ -913,7 +850,7 @@ invalid-syntax = This is { $missing let temp_dir = create_test_locales_dir(); let locale = LanguageIdentifier::from_str("de-DE").unwrap(); // No German file - let result = init_localization(&locale, temp_dir.path(), "nonexistent_test_util"); + let result = init_test_localization(&locale, temp_dir.path()); assert!(result.is_ok()); // Should use English as primary since German failed to load @@ -931,11 +868,11 @@ invalid-syntax = This is { $missing let locale = LanguageIdentifier::from_str("en-US").unwrap(); // Initialize once - let result1 = init_localization(&locale, temp_dir.path(), "test"); + let result1 = init_test_localization(&locale, temp_dir.path()); assert!(result1.is_ok()); // Try to initialize again - should fail - let result2 = init_localization(&locale, temp_dir.path(), "test"); + let result2 = init_test_localization(&locale, temp_dir.path()); assert!(result2.is_err()); match result2 { @@ -955,7 +892,7 @@ invalid-syntax = This is { $missing let temp_dir = create_test_locales_dir(); let locale = LanguageIdentifier::from_str("fr-FR").unwrap(); - init_localization(&locale, temp_dir.path(), "nonexistent_test_util").unwrap(); + init_test_localization(&locale, temp_dir.path()).unwrap(); let message = get_message("greeting"); assert_eq!(message, "Bonjour, le monde!"); @@ -964,16 +901,6 @@ invalid-syntax = This is { $missing .unwrap(); } - #[test] - fn test_get_message_not_initialized() { - std::thread::spawn(|| { - let message = get_message("greeting"); - assert_eq!(message, "greeting"); // Should return the ID itself - }) - .join() - .unwrap(); - } - #[test] fn test_get_message_with_args() { use fluent::FluentArgs; @@ -981,7 +908,7 @@ invalid-syntax = This is { $missing let temp_dir = create_test_locales_dir(); let locale = LanguageIdentifier::from_str("en-US").unwrap(); - init_localization(&locale, temp_dir.path(), "nonexistent_test_util").unwrap(); + init_test_localization(&locale, temp_dir.path()).unwrap(); let mut args = FluentArgs::new(); args.set("name".to_string(), "Bob".to_string()); @@ -1000,7 +927,7 @@ invalid-syntax = This is { $missing let temp_dir = create_test_locales_dir(); let locale = LanguageIdentifier::from_str("en-US").unwrap(); - init_localization(&locale, temp_dir.path(), "nonexistent_test_util").unwrap(); + init_test_localization(&locale, temp_dir.path()).unwrap(); // Test singular let mut args1 = FluentArgs::new(); @@ -1018,6 +945,269 @@ invalid-syntax = This is { $missing .unwrap(); } + #[test] + fn test_thread_local_isolation() { + use std::thread; + + let temp_dir = create_test_locales_dir(); + + // Initialize in main thread with French + let temp_path_main = temp_dir.path().to_path_buf(); + let main_handle = thread::spawn(move || { + let locale = LanguageIdentifier::from_str("fr-FR").unwrap(); + init_test_localization(&locale, &temp_path_main).unwrap(); + let main_message = get_message("greeting"); + assert_eq!(main_message, "Bonjour, le monde!"); + }); + main_handle.join().unwrap(); + + // Test in a different thread - should not be initialized + let temp_path = temp_dir.path().to_path_buf(); + let handle = thread::spawn(move || { + // This thread should have its own uninitialized LOCALIZER + let thread_message = get_message("greeting"); + assert_eq!(thread_message, "greeting"); // Returns ID since not initialized + + // Initialize in this thread with English + let en_locale = LanguageIdentifier::from_str("en-US").unwrap(); + init_test_localization(&en_locale, &temp_path).unwrap(); + let thread_message_after_init = get_message("greeting"); + assert_eq!(thread_message_after_init, "Hello, world!"); + }); + + handle.join().unwrap(); + + // Test another thread to verify French doesn't persist across threads + let final_handle = thread::spawn(move || { + // Should be uninitialized again + let final_message = get_message("greeting"); + assert_eq!(final_message, "greeting"); + }); + final_handle.join().unwrap(); + } + + #[test] + fn test_japanese_localization() { + use fluent::FluentArgs; + std::thread::spawn(|| { + let temp_dir = create_test_locales_dir(); + let locale = LanguageIdentifier::from_str("ja-JP").unwrap(); + + let result = init_test_localization(&locale, temp_dir.path()); + assert!(result.is_ok()); + + // Test Japanese greeting + let message = get_message("greeting"); + assert_eq!(message, "こんにちは、世界!"); + + // Test Japanese with arguments + let mut args = FluentArgs::new(); + args.set("name".to_string(), "田中".to_string()); + let welcome = get_message_with_args("welcome", args); + assert_eq!(welcome, "ようこそ、田中さん!"); + + // Test Japanese count (no pluralization) + let mut count_args = FluentArgs::new(); + count_args.set("count".to_string(), "5".to_string()); + let count_message = get_message_with_args("count-items", count_args); + assert_eq!(count_message, "5個のアイテムがあります"); + }) + .join() + .unwrap(); + } + + #[test] + fn test_arabic_localization() { + use fluent::FluentArgs; + std::thread::spawn(|| { + let temp_dir = create_test_locales_dir(); + let locale = LanguageIdentifier::from_str("ar-SA").unwrap(); + + let result = init_test_localization(&locale, temp_dir.path()); + assert!(result.is_ok()); + + // Test Arabic greeting (RTL text) + let message = get_message("greeting"); + assert_eq!(message, "أهلاً بالعالم!"); + + // Test Arabic with arguments + let mut args = FluentArgs::new(); + args.set("name", "أحمد".to_string()); + let welcome = get_message_with_args("welcome", args); + assert_eq!(welcome, "أهلاً وسهلاً، أحمد!"); + + // Test Arabic pluralization (zero case) + let mut args_zero = FluentArgs::new(); + args_zero.set("count", 0); + let message_zero = get_message_with_args("count-items", args_zero); + assert_eq!(message_zero, "لديك لا عناصر"); + + // Test Arabic pluralization (one case) + let mut args_one = FluentArgs::new(); + args_one.set("count", 1); + let message_one = get_message_with_args("count-items", args_one); + assert_eq!(message_one, "لديك عنصر واحد"); + + // Test Arabic pluralization (two case) + let mut args_two = FluentArgs::new(); + args_two.set("count", 2); + let message_two = get_message_with_args("count-items", args_two); + assert_eq!(message_two, "لديك عنصران"); + + // Test Arabic pluralization (few case - 3-10) + let mut args_few = FluentArgs::new(); + args_few.set("count", 5); + let message_few = get_message_with_args("count-items", args_few); + assert_eq!(message_few, "لديك 5 عناصر"); + + // Test Arabic pluralization (other case - 11+) + let mut args_many = FluentArgs::new(); + args_many.set("count", 15); + let message_many = get_message_with_args("count-items", args_many); + assert_eq!(message_many, "لديك 15 عنصر"); + }) + .join() + .unwrap(); + } + + #[test] + fn test_arabic_localization_with_macro() { + std::thread::spawn(|| { + use self::translate; + let temp_dir = create_test_locales_dir(); + let locale = LanguageIdentifier::from_str("ar-SA").unwrap(); + + let result = init_test_localization(&locale, temp_dir.path()); + assert!(result.is_ok()); + + // Test Arabic greeting (RTL text) + let message = translate!("greeting"); + assert_eq!(message, "أهلاً بالعالم!"); + + // Test Arabic with arguments + let welcome = translate!("welcome", "name" => "أحمد"); + assert_eq!(welcome, "أهلاً وسهلاً، أحمد!"); + + // Test Arabic pluralization (zero case) + let message_zero = translate!("count-items", "count" => 0); + assert_eq!(message_zero, "لديك لا عناصر"); + + // Test Arabic pluralization (one case) + let message_one = translate!("count-items", "count" => 1); + assert_eq!(message_one, "لديك عنصر واحد"); + + // Test Arabic pluralization (two case) + let message_two = translate!("count-items", "count" => 2); + assert_eq!(message_two, "لديك عنصران"); + + // Test Arabic pluralization (few case - 3-10) + let message_few = translate!("count-items", "count" => 5); + assert_eq!(message_few, "لديك 5 عناصر"); + + // Test Arabic pluralization (other case - 11+) + let message_many = translate!("count-items", "count" => 15); + assert_eq!(message_many, "لديك 15 عنصر"); + }) + .join() + .unwrap(); + } + + #[test] + fn test_mixed_script_fallback() { + std::thread::spawn(|| { + let temp_dir = create_test_locales_dir(); + let locale = LanguageIdentifier::from_str("ar-SA").unwrap(); + + let result = init_test_localization(&locale, temp_dir.path()); + assert!(result.is_ok()); + + // Test Arabic message exists + let arabic_message = get_message("greeting"); + assert_eq!(arabic_message, "أهلاً بالعالم!"); + + // Test fallback to English for missing message + let fallback_message = get_message("missing-in-other"); + assert_eq!(fallback_message, "This message only exists in English"); + }) + .join() + .unwrap(); + } + + #[test] + fn test_unicode_directional_isolation_disabled() { + use fluent::FluentArgs; + std::thread::spawn(|| { + let temp_dir = create_test_locales_dir(); + let locale = LanguageIdentifier::from_str("ar-SA").unwrap(); + + init_test_localization(&locale, temp_dir.path()).unwrap(); + + // Test that Latin script names are NOT isolated in RTL context + // since we disabled Unicode directional isolation + let mut args = FluentArgs::new(); + args.set("name".to_string(), "John Smith".to_string()); + let message = get_message_with_args("welcome", args); + + // The Latin name should NOT be wrapped in directional isolate characters + assert!(!message.contains("\u{2068}John Smith\u{2069}")); + assert_eq!(message, "أهلاً وسهلاً، John Smith!"); + }) + .join() + .unwrap(); + } + + #[test] + fn test_parse_resource_error_includes_snippet() { + let temp_dir = create_test_locales_dir(); + let locale = LanguageIdentifier::from_str("es-ES").unwrap(); + + let result = create_test_bundle(&locale, temp_dir.path()); + assert!(result.is_err()); + + if let Err(LocalizationError::ParseResource { + error: _err, + snippet, + }) = result + { + // The snippet should contain exactly the invalid text from es-ES.ftl + assert!( + snippet.contains("This is { $missing"), + "snippet was `{snippet}` but did not include the invalid text" + ); + } else { + panic!("Expected LocalizationError::ParseResource with snippet"); + } + } + + #[test] + fn test_localization_error_from_io_error() { + let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found"); + let loc_error = LocalizationError::from(io_error); + + match loc_error { + LocalizationError::Io { source: _, path } => { + assert_eq!(path, PathBuf::from("")); + } + _ => panic!("Expected IO error variant"), + } + } + + #[test] + fn test_localization_error_uerror_impl() { + let error = LocalizationError::Bundle("some error".to_string()); + assert_eq!(error.code(), 1); + } + + #[test] + fn test_get_message_not_initialized() { + std::thread::spawn(|| { + let message = get_message("greeting"); + assert_eq!(message, "greeting"); // Should return the ID itself + }) + .join() + .unwrap(); + } + #[test] fn test_detect_system_locale_from_lang_env() { // Test locale parsing logic directly instead of relying on environment variables @@ -1066,20 +1256,19 @@ invalid-syntax = This is { $missing #[test] fn test_setup_localization_success() { std::thread::spawn(|| { - let temp_dir = create_test_locales_dir(); - // Save current LANG value let original_lang = env::var("LANG").ok(); unsafe { - env::set_var("LANG", "fr-FR.UTF-8"); + env::set_var("LANG", "en-US.UTF-8"); // Use English since we have embedded resources for "test" } - let result = setup_localization(temp_dir.path().to_str().unwrap()); + let result = setup_localization("test"); assert!(result.is_ok()); - // Test that French is loaded - let message = get_message("greeting"); - assert_eq!(message, "Bonjour, le monde!"); + // Test that we can get messages (should use embedded English for "test" utility) + let message = get_message("test-about"); + // Since we're using embedded resources, we should get the expected message + assert!(!message.is_empty()); // Restore original LANG value if let Some(val) = original_lang { @@ -1099,20 +1288,18 @@ invalid-syntax = This is { $missing #[test] fn test_setup_localization_falls_back_to_english() { std::thread::spawn(|| { - let temp_dir = create_test_locales_dir(); - // Save current LANG value let original_lang = env::var("LANG").ok(); unsafe { - env::set_var("LANG", "de-DE.UTF-8"); - } // German file doesn't exist + env::set_var("LANG", "de-DE.UTF-8"); // German file doesn't exist, should fallback + } - let result = setup_localization(temp_dir.path().to_str().unwrap()); + let result = setup_localization("test"); assert!(result.is_ok()); - // Should fall back to English - let message = get_message("greeting"); - assert_eq!(message, "Hello, world!"); + // Should fall back to English embedded resources + let message = get_message("test-about"); + assert!(!message.is_empty()); // Should get something, not just the key // Restore original LANG value if let Some(val) = original_lang { @@ -1153,218 +1340,6 @@ invalid-syntax = This is { $missing .unwrap(); } - #[test] - fn test_thread_local_isolation() { - use std::thread; - - let temp_dir = create_test_locales_dir(); - - // Initialize in main thread with French - let temp_path_main = temp_dir.path().to_path_buf(); - let main_handle = thread::spawn(move || { - let locale = LanguageIdentifier::from_str("fr-FR").unwrap(); - init_localization(&locale, &temp_path_main, "nonexistent_test_util").unwrap(); - let main_message = get_message("greeting"); - assert_eq!(main_message, "Bonjour, le monde!"); - }); - main_handle.join().unwrap(); - - // Test in a different thread - should not be initialized - let temp_path = temp_dir.path().to_path_buf(); - let handle = thread::spawn(move || { - // This thread should have its own uninitialized LOCALIZER - let thread_message = get_message("greeting"); - assert_eq!(thread_message, "greeting"); // Returns ID since not initialized - - // Initialize in this thread with English - let en_locale = LanguageIdentifier::from_str("en-US").unwrap(); - init_localization(&en_locale, &temp_path, "nonexistent_test_util").unwrap(); - let thread_message_after_init = get_message("greeting"); - assert_eq!(thread_message_after_init, "Hello, world!"); - }); - - handle.join().unwrap(); - - // Test another thread to verify French doesn't persist across threads - let final_handle = thread::spawn(move || { - // Should be uninitialized again - let final_message = get_message("greeting"); - assert_eq!(final_message, "greeting"); - }); - final_handle.join().unwrap(); - } - - #[test] - fn test_japanese_localization() { - use fluent::FluentArgs; - std::thread::spawn(|| { - let temp_dir = create_test_locales_dir(); - let locale = LanguageIdentifier::from_str("ja-JP").unwrap(); - - let result = init_localization(&locale, temp_dir.path(), "nonexistent_test_util"); - assert!(result.is_ok()); - - // Test Japanese greeting - let message = get_message("greeting"); - assert_eq!(message, "こんにちは、世界!"); - - // Test Japanese with arguments - let mut args = FluentArgs::new(); - args.set("name".to_string(), "田中".to_string()); - let welcome = get_message_with_args("welcome", args); - assert_eq!(welcome, "ようこそ、田中さん!"); - - // Test Japanese count (no pluralization) - let mut count_args = FluentArgs::new(); - count_args.set("count".to_string(), "5".to_string()); - let count_message = get_message_with_args("count-items", count_args); - assert_eq!(count_message, "5個のアイテムがあります"); - }) - .join() - .unwrap(); - } - - #[test] - fn test_arabic_localization() { - use fluent::FluentArgs; - std::thread::spawn(|| { - let temp_dir = create_test_locales_dir(); - let locale = LanguageIdentifier::from_str("ar-SA").unwrap(); - - let result = init_localization(&locale, temp_dir.path(), "nonexistent_test_util"); - assert!(result.is_ok()); - - // Test Arabic greeting (RTL text) - let message = get_message("greeting"); - assert_eq!(message, "أهلاً بالعالم!"); - - // Test Arabic with arguments - let mut args = FluentArgs::new(); - args.set("name", "أحمد".to_string()); - let welcome = get_message_with_args("welcome", args); - - assert_eq!(welcome, "أهلاً وسهلاً، أحمد!"); - - // Test Arabic pluralization (zero case) - let mut args_zero = FluentArgs::new(); - args_zero.set("count", 0); - let message_zero = get_message_with_args("count-items", args_zero); - assert_eq!(message_zero, "لديك لا عناصر"); - - // Test Arabic pluralization (one case) - let mut args_one = FluentArgs::new(); - args_one.set("count", 1); - let message_one = get_message_with_args("count-items", args_one); - assert_eq!(message_one, "لديك عنصر واحد"); - - // Test Arabic pluralization (two case) - let mut args_two = FluentArgs::new(); - args_two.set("count", 2); - let message_two = get_message_with_args("count-items", args_two); - assert_eq!(message_two, "لديك عنصران"); - - // Test Arabic pluralization (few case - 3-10) - let mut args_few = FluentArgs::new(); - args_few.set("count", 5); - let message_few = get_message_with_args("count-items", args_few); - assert_eq!(message_few, "لديك 5 عناصر"); - - // Test Arabic pluralization (other case - 11+) - let mut args_many = FluentArgs::new(); - args_many.set("count", 15); - let message_many = get_message_with_args("count-items", args_many); - assert_eq!(message_many, "لديك 15 عنصر"); - }) - .join() - .unwrap(); - } - - #[test] - fn test_arabic_localization_with_macro() { - std::thread::spawn(|| { - use self::translate; - let temp_dir = create_test_locales_dir(); - let locale = LanguageIdentifier::from_str("ar-SA").unwrap(); - - let result = init_localization(&locale, temp_dir.path(), "nonexistent_test_util"); - assert!(result.is_ok()); - - // Test Arabic greeting (RTL text) - let message = translate!("greeting"); - assert_eq!(message, "أهلاً بالعالم!"); - - // Test Arabic with arguments - let welcome = translate!("welcome", "name" => "أحمد"); - assert_eq!(welcome, "أهلاً وسهلاً، أحمد!"); - - // Test Arabic pluralization (zero case) - let message_zero = translate!("count-items", "count" => 0); - assert_eq!(message_zero, "لديك لا عناصر"); - - // Test Arabic pluralization (one case) - let message_one = translate!("count-items", "count" => 1); - assert_eq!(message_one, "لديك عنصر واحد"); - - // Test Arabic pluralization (two case) - let message_two = translate!("count-items", "count" => 2); - assert_eq!(message_two, "لديك عنصران"); - - // Test Arabic pluralization (few case - 3-10) - let message_few = translate!("count-items", "count" => 5); - assert_eq!(message_few, "لديك 5 عناصر"); - - // Test Arabic pluralization (other case - 11+) - let message_many = translate!("count-items", "count" => 15); - assert_eq!(message_many, "لديك 15 عنصر"); - }) - .join() - .unwrap(); - } - - #[test] - fn test_mixed_script_fallback() { - std::thread::spawn(|| { - let temp_dir = create_test_locales_dir(); - let locale = LanguageIdentifier::from_str("ar-SA").unwrap(); - - let result = init_localization(&locale, temp_dir.path(), "nonexistent_test_util"); - assert!(result.is_ok()); - - // Test Arabic message exists - let arabic_message = get_message("greeting"); - assert_eq!(arabic_message, "أهلاً بالعالم!"); - - // Test fallback to English for missing message - let fallback_message = get_message("missing-in-other"); - assert_eq!(fallback_message, "This message only exists in English"); - }) - .join() - .unwrap(); - } - - #[test] - fn test_unicode_directional_isolation_disabled() { - use fluent::FluentArgs; - std::thread::spawn(|| { - let temp_dir = create_test_locales_dir(); - let locale = LanguageIdentifier::from_str("ar-SA").unwrap(); - - init_localization(&locale, temp_dir.path(), "nonexistent_test_util").unwrap(); - - // Test that Latin script names are NOT isolated in RTL context - // since we disabled Unicode directional isolation - let mut args = FluentArgs::new(); - args.set("name".to_string(), "John Smith".to_string()); - let message = get_message_with_args("welcome", args); - - // The Latin name should NOT be wrapped in directional isolate characters - assert!(!message.contains("\u{2068}John Smith\u{2069}")); - assert_eq!(message, "أهلاً وسهلاً، John Smith!"); - }) - .join() - .unwrap(); - } - #[test] fn test_error_display() { let io_error = LocalizationError::Io { @@ -1380,29 +1355,6 @@ invalid-syntax = This is { $missing assert!(bundle_string.contains("Bundle error: Bundle creation failed")); } - #[test] - fn test_parse_resource_error_includes_snippet() { - let temp_dir = create_test_locales_dir(); - let locale = LanguageIdentifier::from_str("es-ES").unwrap(); - - let result = create_bundle(&locale, temp_dir.path()); - assert!(result.is_err()); - - if let Err(LocalizationError::ParseResource { - error: _err, - snippet, - }) = result - { - // The snippet should contain exactly the invalid text from es-ES.ftl - assert!( - snippet.contains("This is { $missing"), - "snippet was `{snippet}` but did not include the invalid text" - ); - } else { - panic!("Expected LocalizationError::ParseResource with snippet"); - } - } - #[test] fn test_clap_localization_fallbacks() { std::thread::spawn(|| { @@ -1416,11 +1368,11 @@ invalid-syntax = This is { $missing let tip_msg = get_message("common-tip"); assert_eq!(tip_msg, "common-tip"); // Should return key when not initialized - // Now initialize with setup_localization_with_common - let result = setup_localization_with_common("comm"); + // Now initialize with setup_localization + let result = setup_localization("comm"); if result.is_err() { // If setup fails (e.g., no embedded locales for comm), try with a known utility - let _ = setup_localization_with_common("test"); + let _ = setup_localization("test"); } // Test that common strings are available after initialization