rm: hint to use ./-foo when removing a dash-prefixed file

This commit is contained in:
Sylvestre Ledru
2026-06-03 20:30:00 +02:00
parent 4a41104d38
commit dab93926d2
5 changed files with 76 additions and 3 deletions
+1 -1
View File
@@ -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 }
+2
View File
@@ -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
+2
View File
@@ -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
+37 -2
View File
@@ -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<OsString> = 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::<OsString>(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<dyn UError> {
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!())
+34
View File
@@ -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");
}