From 4f44031af6e587a5eeff1033600b1da0c3c00c9d Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 22 Mar 2026 21:33:41 +0100 Subject: [PATCH 1/4] uudoc: fix resolution of shared Fluent keys in option help text https://uutils.github.io/coreutils/docs-fr/utils/base32.html https://uutils.github.io/coreutils/docs/utils/sha224sum.html Affected pages: base32, base64, basenc (both English and all translated versions). --- src/bin/uudoc.rs | 135 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 129 insertions(+), 6 deletions(-) diff --git a/src/bin/uudoc.rs b/src/bin/uudoc.rs index 64959bc12df..f8758042c80 100644 --- a/src/bin/uudoc.rs +++ b/src/bin/uudoc.rs @@ -615,12 +615,10 @@ impl MDWriter<'_, '_> { } writeln!(self.w, "")?; let help_text = arg.get_help().unwrap_or_default().to_string(); - // Try to resolve Fluent key if it looks like one, otherwise use as-is - let resolved_help = if help_text.starts_with(&format!("{}-help-", self.fluent_key)) { - self.extract_fluent_value(&help_text).unwrap_or(help_text) - } else { - help_text - }; + // Try to resolve Fluent key from the FTL file, otherwise use help text as-is. + // We always attempt resolution because shared keys (e.g. "base-common-help-*") + // don't necessarily start with the utility-specific prefix. + let resolved_help = self.extract_fluent_value(&help_text).unwrap_or(help_text); writeln!( self.w, "
\n\n{}\n\n
", @@ -856,4 +854,129 @@ mod tests { let result = post_process_manpage(input.to_string(), "2024-01-01"); assert_eq!(result, expected); } + + /// Helper to create an MDWriter with given FTL content for testing + fn make_test_writer(fluent_content: &str, fluent_key: &str) -> MDWriter<'static, 'static> { + // Leak the HashMap to get a 'static reference (fine in tests) + let platforms: &'static HashMap<&'static str, Vec> = + Box::leak(Box::new(HashMap::new())); + let tldr_zip: &'static mut Option> = Box::leak(Box::new(None)); + MDWriter { + w: Box::new(Vec::new()), + command: Command::new("test"), + name: "test", + tldr_zip, + utils_per_platform: platforms, + fluent: Some(fluent_content.to_string()), + fluent_key: fluent_key.to_string(), + } + } + + #[test] + fn test_extract_fluent_value_resolves_utility_specific_keys() { + let ftl = "base32-about = encode/decode data and print to standard output\n\ + base32-usage = base32 [OPTION]... [FILE]\n"; + let writer = make_test_writer(ftl, "base32"); + + assert_eq!( + writer.extract_fluent_value("base32-about"), + Some("encode/decode data and print to standard output".to_string()) + ); + assert_eq!( + writer.extract_fluent_value("base32-usage"), + Some("base32 [OPTION]... [FILE]".to_string()) + ); + } + + #[test] + fn test_extract_fluent_value_resolves_shared_keys() { + // Regression test: shared Fluent keys like "base-common-help-decode" + // don't start with the utility prefix "base32-". They must still be + // resolved from the same FTL file. + let ftl = "base32-about = encode/decode data\n\ + base-common-help-decode = decode data\n\ + base-common-help-ignore-garbage = when decoding, ignore non-alphabet characters\n"; + let writer = make_test_writer(ftl, "base32"); + + assert_eq!( + writer.extract_fluent_value("base-common-help-decode"), + Some("decode data".to_string()) + ); + assert_eq!( + writer.extract_fluent_value("base-common-help-ignore-garbage"), + Some("when decoding, ignore non-alphabet characters".to_string()) + ); + } + + #[test] + fn test_extract_fluent_value_returns_none_for_missing_keys() { + let ftl = "base32-about = encode/decode data\n"; + let writer = make_test_writer(ftl, "base32"); + + assert_eq!(writer.extract_fluent_value("nonexistent-key"), None); + } + + #[test] + fn test_extract_fluent_value_returns_none_when_no_ftl() { + let platforms: &'static HashMap<&'static str, Vec> = + Box::leak(Box::new(HashMap::new())); + let tldr_zip: &'static mut Option> = Box::leak(Box::new(None)); + let writer = MDWriter { + w: Box::new(Vec::new()), + command: Command::new("test"), + name: "test", + tldr_zip, + utils_per_platform: platforms, + fluent: None, + fluent_key: "test".to_string(), + }; + + assert_eq!(writer.extract_fluent_value("any-key"), None); + } + + #[test] + fn test_options_resolves_shared_fluent_keys_in_help_text() { + // End-to-end test: an option whose help text is a shared Fluent key + // must have that key resolved in the generated markdown output. + let ftl = "base-common-help-decode = decode data\n"; + let command = Command::new("base32").arg( + Arg::new("decode") + .short('d') + .long("decode") + .help("base-common-help-decode") + .action(clap::ArgAction::SetTrue), + ); + let platforms: &'static HashMap<&'static str, Vec> = + Box::leak(Box::new(HashMap::new())); + let tldr_zip: &'static mut Option> = Box::leak(Box::new(None)); + let mut writer = MDWriter { + w: Box::new(Vec::::new()), + command, + name: "base32", + tldr_zip, + utils_per_platform: platforms, + fluent: Some(ftl.to_string()), + fluent_key: "base32".to_string(), + }; + + writer.options().unwrap(); + + // Recover the output buffer + let output = { + let buf = writer.w.as_ref() as *const dyn Write as *const Vec; + // SAFETY: we know the writer wraps a Vec + unsafe { &*buf } + }; + let html = String::from_utf8_lossy(output); + + // The resolved text "decode data" must appear, NOT the raw key + assert!( + html.contains("decode data"), + "Expected resolved help text 'decode data', got:\n{html}" + ); + assert!( + !html.contains("base-common-help-decode"), + "Raw Fluent key should not appear in output, got:\n{html}" + ); + } } From d08164abb8528112455193d2bde6b394c89c2da3 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 22 Mar 2026 22:04:59 +0100 Subject: [PATCH 2/4] uudoc: resolve shared Fluent keys across utility crates Load all FTL files from src/uu/*/locales/ and always attempt key resolution, fixing raw identifiers like "base-common-help-decode" and "ck-common-help-check" appearing in generated docs. --- src/bin/uudoc.rs | 142 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 134 insertions(+), 8 deletions(-) diff --git a/src/bin/uudoc.rs b/src/bin/uudoc.rs index f8758042c80..0368951ecdf 100644 --- a/src/bin/uudoc.rs +++ b/src/bin/uudoc.rs @@ -8,8 +8,9 @@ use std::{ collections::HashMap, ffi::OsString, - fs::File, + fs::{self, File}, io::{self, Read, Seek, Write}, + path::Path, process, }; @@ -350,6 +351,12 @@ fn main() -> io::Result<()> { } } + // Load all FTL files from all utility directories into a combined string. + // This allows utilities that share keys across crates (e.g., sha224sum using + // ck-common-help-* from checksum_common, or base64 using base-common-help-* + // from base32) to resolve those keys during doc generation. + let all_fluent = load_all_ftl("src/uu"); + println!("Writing to utils"); for (&name, (_, command)) in utils { let (utils_name, usage_name, command) = match name { @@ -360,13 +367,11 @@ fn main() -> io::Result<()> { }; let p = format!("docs/src/utils/{usage_name}.md"); - let fluent = File::open(format!("src/uu/{utils_name}/locales/en-US.ftl")) - .and_then(|mut f: File| { - let mut s = String::new(); - f.read_to_string(&mut s)?; - Ok(s) - }) - .ok(); + let fluent = if all_fluent.is_empty() { + None + } else { + Some(all_fluent.clone()) + }; if let Ok(f) = File::create(&p) { MDWriter { @@ -388,6 +393,26 @@ fn main() -> io::Result<()> { Ok(()) } +/// Load and concatenate all `en-US.ftl` files from utility locale directories. +/// This allows shared Fluent keys (e.g., `ck-common-help-*` in `checksum_common`) +/// to be resolved when generating docs for utilities that depend on them. +fn load_all_ftl(uu_dir: &str) -> String { + let mut combined = String::new(); + let base = Path::new(uu_dir); + if let Ok(entries) = fs::read_dir(base) { + for entry in entries.flatten() { + let ftl_path = entry.path().join("locales/en-US.ftl"); + if ftl_path.is_file() { + if let Ok(content) = fs::read_to_string(&ftl_path) { + combined.push('\n'); + combined.push_str(&content); + } + } + } + } + combined +} + fn fix_usage(name: &str, usage: String) -> String { match name { "test" => { @@ -979,4 +1004,105 @@ mod tests { "Raw Fluent key should not appear in output, got:\n{html}" ); } + + #[test] + fn test_options_resolves_cross_crate_fluent_keys() { + // Simulates sha224sum: its own FTL only has sha224sum-about/usage, + // but options use ck-common-help-* keys from checksum_common's FTL. + // When all FTL files are combined, these keys must resolve. + let combined_ftl = "\ +sha224sum-about = Print or check the SHA224 checksums +sha224sum-usage = sha224sum [OPTIONS] [FILE]... +ck-common-help-check = read checksums from the FILEs and check them +ck-common-help-warn = warn about improperly formatted checksum lines +ck-common-help-status = don't output anything, status code shows success +"; + let command = Command::new("sha224sum") + .arg( + Arg::new("check") + .short('c') + .long("check") + .help("ck-common-help-check") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("warn") + .short('w') + .long("warn") + .help("ck-common-help-warn") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("status") + .long("status") + .help("ck-common-help-status") + .action(clap::ArgAction::SetTrue), + ); + let platforms: &'static HashMap<&'static str, Vec> = + Box::leak(Box::new(HashMap::new())); + let tldr_zip: &'static mut Option> = Box::leak(Box::new(None)); + let mut writer = MDWriter { + w: Box::new(Vec::::new()), + command, + name: "sha224sum", + tldr_zip, + utils_per_platform: platforms, + fluent: Some(combined_ftl.to_string()), + fluent_key: "sha224sum".to_string(), + }; + + writer.options().unwrap(); + + let output = { + let buf = writer.w.as_ref() as *const dyn Write as *const Vec; + unsafe { &*buf } + }; + let html = String::from_utf8_lossy(output); + + assert!( + html.contains("read checksums from the FILEs and check them"), + "Expected resolved ck-common-help-check, got:\n{html}" + ); + assert!( + !html.contains("ck-common-help-check"), + "Raw Fluent key ck-common-help-check should not appear, got:\n{html}" + ); + assert!( + html.contains("warn about improperly formatted checksum lines"), + "Expected resolved ck-common-help-warn, got:\n{html}" + ); + assert!( + html.contains("don't output anything, status code shows success"), + "Expected resolved ck-common-help-status, got:\n{html}" + ); + } + + #[test] + fn test_load_all_ftl_combines_files() { + // Create a temporary directory structure mirroring src/uu/*/locales/ + let tmp = std::env::temp_dir().join("uudoc_test_ftl"); + let _ = fs::remove_dir_all(&tmp); + + let util_a = tmp.join("util_a/locales"); + let util_b = tmp.join("util_b/locales"); + fs::create_dir_all(&util_a).unwrap(); + fs::create_dir_all(&util_b).unwrap(); + + fs::write(util_a.join("en-US.ftl"), "key-a = value A\n").unwrap(); + fs::write(util_b.join("en-US.ftl"), "key-b = value B\n").unwrap(); + + let combined = load_all_ftl(tmp.to_str().unwrap()); + + assert!(combined.contains("key-a = value A"), "Missing key-a"); + assert!(combined.contains("key-b = value B"), "Missing key-b"); + + // Clean up + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn test_load_all_ftl_handles_missing_dir() { + let result = load_all_ftl("/nonexistent/path"); + assert!(result.is_empty()); + } } From abf1deaf57e054c47e57d860f8bbe3071ac2c49b Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 22 Mar 2026 22:18:45 +0100 Subject: [PATCH 3/4] fix: remove unnecessary std::fs qualification --- src/bin/uudoc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/uudoc.rs b/src/bin/uudoc.rs index 0368951ecdf..bcf900f6fd5 100644 --- a/src/bin/uudoc.rs +++ b/src/bin/uudoc.rs @@ -250,7 +250,7 @@ fn main() -> io::Result<()> { let utils = util_map::>>(); // Initialize localization for uucore common strings (used by tldr example attribution) let _ = uucore::locale::setup_localization("uudoc"); - match std::fs::create_dir("docs/src/utils/") { + match fs::create_dir("docs/src/utils/") { Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()), x => x, }?; From faa158c4ad533473f55fd193b7eec3be0f4aa805 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 22 Mar 2026 22:28:15 +0100 Subject: [PATCH 4/4] fix: replace unsafe pointer casts with SharedBuf in tests --- src/bin/uudoc.rs | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/bin/uudoc.rs b/src/bin/uudoc.rs index bcf900f6fd5..54ed465f2e9 100644 --- a/src/bin/uudoc.rs +++ b/src/bin/uudoc.rs @@ -959,6 +959,28 @@ mod tests { assert_eq!(writer.extract_fluent_value("any-key"), None); } + /// A shared buffer that implements Write for use in tests. + #[derive(Clone)] + struct SharedBuf(std::sync::Arc>>); + + impl SharedBuf { + fn new() -> Self { + Self(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))) + } + fn to_string(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } + } + + impl Write for SharedBuf { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().write(buf) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + #[test] fn test_options_resolves_shared_fluent_keys_in_help_text() { // End-to-end test: an option whose help text is a shared Fluent key @@ -974,8 +996,9 @@ mod tests { let platforms: &'static HashMap<&'static str, Vec> = Box::leak(Box::new(HashMap::new())); let tldr_zip: &'static mut Option> = Box::leak(Box::new(None)); + let buf = SharedBuf::new(); let mut writer = MDWriter { - w: Box::new(Vec::::new()), + w: Box::new(buf.clone()), command, name: "base32", tldr_zip, @@ -985,14 +1008,7 @@ mod tests { }; writer.options().unwrap(); - - // Recover the output buffer - let output = { - let buf = writer.w.as_ref() as *const dyn Write as *const Vec; - // SAFETY: we know the writer wraps a Vec - unsafe { &*buf } - }; - let html = String::from_utf8_lossy(output); + let html = buf.to_string(); // The resolved text "decode data" must appear, NOT the raw key assert!( @@ -1041,8 +1057,9 @@ ck-common-help-status = don't output anything, status code shows success let platforms: &'static HashMap<&'static str, Vec> = Box::leak(Box::new(HashMap::new())); let tldr_zip: &'static mut Option> = Box::leak(Box::new(None)); + let buf = SharedBuf::new(); let mut writer = MDWriter { - w: Box::new(Vec::::new()), + w: Box::new(buf.clone()), command, name: "sha224sum", tldr_zip, @@ -1052,12 +1069,7 @@ ck-common-help-status = don't output anything, status code shows success }; writer.options().unwrap(); - - let output = { - let buf = writer.w.as_ref() as *const dyn Write as *const Vec; - unsafe { &*buf } - }; - let html = String::from_utf8_lossy(output); + let html = buf.to_string(); assert!( html.contains("read checksums from the FILEs and check them"),