From dab93926d25269e0fdbc5445e40b71ee0d44ab8a Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Wed, 3 Jun 2026 20:30:00 +0200 Subject: [PATCH] rm: hint to use ./-foo when removing a dash-prefixed file --- src/uu/rm/Cargo.toml | 2 +- src/uu/rm/locales/en-US.ftl | 2 ++ src/uu/rm/locales/fr-FR.ftl | 2 ++ src/uu/rm/src/rm.rs | 39 +++++++++++++++++++++++++++++++++++-- tests/by-util/test_rm.rs | 34 ++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/uu/rm/Cargo.toml b/src/uu/rm/Cargo.toml index 5ca685ed1..d82fc0b3a 100644 --- a/src/uu/rm/Cargo.toml +++ b/src/uu/rm/Cargo.toml @@ -21,7 +21,7 @@ doctest = false [dependencies] thiserror = { workspace = true } clap = { workspace = true } -uucore = { workspace = true, features = ["fs", "parser"] } +uucore = { workspace = true, features = ["fs", "parser", "quoting-style"] } fluent = { workspace = true } indicatif = { workspace = true } diff --git a/src/uu/rm/locales/en-US.ftl b/src/uu/rm/locales/en-US.ftl index 8a9a3513e..8f24f9359 100644 --- a/src/uu/rm/locales/en-US.ftl +++ b/src/uu/rm/locales/en-US.ftl @@ -36,6 +36,8 @@ rm-progress-removing = Removing # Error messages rm-error-missing-operand = missing operand Try '{$util_name} --help' for more information. +rm-hint-dash-file = Try '{$util_name} ./{$path}' to remove the file {$file}. + Try '{$util_name} --help' for more information. rm-error-cannot-remove-no-such-file = cannot remove {$file}: No such file or directory rm-error-cannot-remove-permission-denied = cannot remove {$file}: Permission denied rm-error-cannot-remove-is-directory = cannot remove {$file}: Is a directory diff --git a/src/uu/rm/locales/fr-FR.ftl b/src/uu/rm/locales/fr-FR.ftl index b49191f67..edbebb765 100644 --- a/src/uu/rm/locales/fr-FR.ftl +++ b/src/uu/rm/locales/fr-FR.ftl @@ -36,6 +36,8 @@ rm-progress-removing = Suppression # Messages d'erreur rm-error-missing-operand = opérande manquant Essayez '{$util_name} --help' pour plus d'informations. +rm-hint-dash-file = Essayez '{$util_name} ./{$path}' pour supprimer le fichier {$file}. + Essayez '{$util_name} --help' pour plus d'informations. rm-error-cannot-remove-no-such-file = impossible de supprimer {$file} : Aucun fichier ou répertoire de ce type rm-error-cannot-remove-permission-denied = impossible de supprimer {$file} : Permission refusée rm-error-cannot-remove-is-directory = impossible de supprimer {$file} : C'est un répertoire diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index f9aba615f..67a502361 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -10,7 +10,7 @@ use clap::{Arg, ArgAction, Command, parser::ValueSource}; use indicatif::{ProgressBar, ProgressStyle}; use std::ffi::{OsStr, OsString}; use std::fs::{self, Metadata}; -use std::io::{self, IsTerminal, stdin}; +use std::io::{self, IsTerminal, Write, stdin}; use std::ops::BitOr; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; @@ -22,6 +22,7 @@ use thiserror::Error; use uucore::display::Quotable; use uucore::error::{FromIo, UError, UResult}; use uucore::parser::shortcut_value_parser::ShortcutValueParser; +use uucore::quoting_style::{QuotingStyle, locale_aware_escape_name}; use uucore::translate; use uucore::{format_usage, os_str_as_bytes, prompt_yes, show_error}; @@ -203,7 +204,9 @@ static ARG_FILES: &str = "files"; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let args: Vec = args.collect(); - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args.iter())?; + let matches = uu_app() + .try_get_matches_from(args.iter()) + .map_err(|e| handle_parse_error(e, &args))?; let files: Vec<_> = matches .get_many::(ARG_FILES) @@ -293,6 +296,38 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Ok(()) } +/// Turn a clap parsing error into a `UError`, reproducing GNU's hint when an +/// unknown `-foo` option is actually the name of an existing file: it suggests +/// `rm ./-foo` so the name is treated as a path. See `tests/rm/dash-hint.sh`. +fn handle_parse_error(e: clap::Error, args: &[OsString]) -> Box { + let dash_file = args.iter().skip(1).find(|a| { + os_str_as_bytes(a).is_ok_and(|b| b.len() >= 2 && b[0] == b'-') + && fs::symlink_metadata(a).is_ok() + }); + if let (true, Some(file)) = (e.exit_code() != 0, dash_file) { + // The path is shell-escaped (quoted only if needed), the file name is + // always quoted, matching GNU's two quoting styles. + let path = locale_aware_escape_name(file, QuotingStyle::SHELL_ESCAPE); + let quoted = locale_aware_escape_name(file, QuotingStyle::SHELL_ESCAPE_QUOTE); + let _ = writeln!( + io::stderr(), + "{}", + translate!( + "rm-hint-dash-file", + "util_name" => uucore::execution_phrase(), + "path" => path.to_string_lossy(), + "file" => quoted.to_string_lossy() + ) + ); + return 1.into(); + } + // Otherwise defer to the standard clap handling (incl. `--help`/`--version`). + match uucore::clap_localization::handle_clap_result(uu_app(), args.iter()) { + Ok(_) => 1.into(), + Err(e) => e, + } +} + pub fn uu_app() -> Command { Command::new("rm") .version(uucore::crate_version!()) diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index 43c712e9c..f226c7b38 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -1581,3 +1581,37 @@ fn test_symlink_to_dot_protection() { assert!(at.file_exists("subdir/file")); assert!(at.file_exists("topfile")); } + +#[test] +fn test_dash_hint() { + // `rm -foo` where a file named "-foo" exists: GNU suggests `rm ./-foo`. + // The invocation prefix (`{$util_name}`) varies in tests, so only the + // stable parts are checked. + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.touch("-foo"); + ts.ucmd() + .arg("-foo") + .fails_with_code(1) + .stderr_contains("./-foo' to remove the file '-foo'.") + .stderr_contains("--help' for more information."); + assert!(at.file_exists("-foo")); + + // The suggestion is shell-escaped so it can be copy-pasted. Newlines are + // not valid in Windows file names, so only exercise this on Unix. + #[cfg(unix)] + { + at.touch("-foo\nbar"); + ts.ucmd() + .arg("-foo\nbar") + .fails_with_code(1) + .stderr_contains("./'-foo'$'\\n''bar'' to remove the file '-foo'$'\\n''bar'."); + } + + // No matching file exists: the hint must not be shown. + ts.ucmd() + .arg("-bar") + .fails_with_code(1) + .stderr_does_not_contain("to remove the file"); +}