diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e9e23d9..ee1d762c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Handle invalid working directories gracefully when using `--full-path`, see #1900 (@Xavrir). - Fire the "search pattern contains a path separator" diagnostic for any pattern containing `/`, not just patterns that happen to name an existing directory. Preserves the legacy Windows behaviour that also flags native `\` separators when the pattern resolves to a real directory. See #1873. - Fix bug where passing "-" as a directory argument didn't actually search that directory, see #849 (@Sean-Kenneth-Doherty). +- Hint that a command is a shell builtin when `-x`/`-X` fails with "command not found", see #1944 (@kimjune01) # 10.4.2 diff --git a/src/exec/command.rs b/src/exec/command.rs index 89522b029..6907a5750 100644 --- a/src/exec/command.rs +++ b/src/exec/command.rs @@ -98,13 +98,37 @@ pub fn execute_commands>>( ExitCode::Success } +/// Common shell builtins that typically do not exist as standalone executables. +/// When fd encounters a "command not found" error for one of these, it hints +/// that the user may be trying to use a shell builtin. +const SHELL_BUILTINS: &[&str] = &[ + ".", "alias", "bg", "bind", "cd", "command", "declare", "dirs", "eval", "exec", "exit", + "export", "fg", "hash", "help", "history", "jobs", "let", "local", "logout", "popd", "pushd", + "read", "readonly", "return", "set", "shift", "shopt", "source", "suspend", "times", "trap", + "type", "typeset", "unalias", "unset", "wait", +]; + +fn is_shell_builtin(program: &str) -> bool { + SHELL_BUILTINS.contains(&program) +} + +fn command_not_found_message(program: &str) -> String { + if is_shell_builtin(program) { + format!( + "Command not found: {program}. Note: {program} is a shell builtin, \ + not a standalone program. To run shell builtins, invoke a shell explicitly, \ + e.g. fd -x sh -c '{program} ... \"$1\"' sh {{}}", + ) + } else { + format!("Command not found: {program}") + } +} + pub fn handle_cmd_error(cmd: Option<&Command>, err: io::Error) -> ExitCode { match (cmd, err) { (Some(cmd), err) if err.kind() == io::ErrorKind::NotFound => { - print_error(format!( - "Command not found: {}", - cmd.get_program().to_string_lossy() - )); + let program = cmd.get_program().to_string_lossy(); + print_error(command_not_found_message(&program)); ExitCode::GeneralError } (_, err) => { @@ -113,3 +137,44 @@ pub fn handle_cmd_error(cmd: Option<&Command>, err: io::Error) -> ExitCode { } } } + +#[cfg(test)] +mod builtin_tests { + use super::*; + + #[test] + fn detects_known_builtins() { + assert!(is_shell_builtin("cd")); + assert!(is_shell_builtin("export")); + assert!(is_shell_builtin("source")); + assert!(is_shell_builtin("eval")); + assert!(is_shell_builtin(".")); + } + + #[test] + fn rejects_non_builtins() { + assert!(!is_shell_builtin("grep")); + assert!(!is_shell_builtin("ls")); + assert!(!is_shell_builtin("")); + assert!(!is_shell_builtin("CD")); + // These typically exist as standalone executables + assert!(!is_shell_builtin("echo")); + assert!(!is_shell_builtin("printf")); + assert!(!is_shell_builtin("test")); + } + + #[test] + fn builtin_message_includes_hint() { + let msg = command_not_found_message("cd"); + assert!(msg.starts_with("Command not found: cd.")); + assert!(msg.contains("shell builtin")); + assert!(msg.contains("sh -c")); + } + + #[test] + fn non_builtin_message_is_plain() { + let msg = command_not_found_message("nonexistent"); + assert_eq!(msg, "Command not found: nonexistent"); + assert!(!msg.contains("shell builtin")); + } +}