From 84fae6e07ef63a4c504573a2e2688e888641a73c Mon Sep 17 00:00:00 2001 From: Reilly Wood <26268125+rgwood@users.noreply.github.com> Date: Sun, 7 Aug 2022 11:40:41 -0700 Subject: [PATCH] Suggest alternative when command not found (#6256) * Suggest alternative when command not found * Add tests for command-not-found suggestions * Put suggestion in label * Fix tests --- crates/nu-command/src/system/run_external.rs | 39 +++++++++++++++++--- crates/nu-command/tests/commands/all.rs | 2 +- crates/nu-command/tests/commands/any.rs | 2 +- tests/shell/pipeline/commands/external.rs | 13 +++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/nu-command/src/system/run_external.rs b/crates/nu-command/src/system/run_external.rs index 5c08898989..354959548e 100644 --- a/crates/nu-command/src/system/run_external.rs +++ b/crates/nu-command/src/system/run_external.rs @@ -2,6 +2,7 @@ use fancy_regex::Regex; use itertools::Itertools; use nu_engine::env_to_strings; use nu_engine::CallExt; +use nu_protocol::did_you_mean; use nu_protocol::engine::{EngineState, Stack}; use nu_protocol::{ast::Call, engine::Command, ShellError, Signature, SyntaxShape, Value}; use nu_protocol::{Category, Example, ListStream, PipelineData, RawStream, Span, Spanned}; @@ -157,11 +158,21 @@ impl ExternalCommand { } match child { - Err(err) => Err(ShellError::ExternalCommand( - "can't run executable".to_string(), - err.to_string(), - self.name.span, - )), + Err(err) => { + // If we try to run an external but can't, there's a good chance + // that the user entered the wrong command name + let suggestion = suggest_command(&self.name.item, engine_state); + let label = match suggestion { + Some(s) => format!("did you mean '{s}'?"), + None => "can't run executable".into(), + }; + + Err(ShellError::ExternalCommand( + label, + err.to_string(), + self.name.span, + )) + } Ok(mut child) => { if !input.is_nothing() { let mut engine_state = engine_state.clone(); @@ -513,6 +524,24 @@ impl ExternalCommand { } } +/// Given an invalid command name, try to suggest an alternative +fn suggest_command(attempted_command: &str, engine_state: &EngineState) -> Option { + let commands = engine_state.get_signatures(false); + let command_name_lower = attempted_command.to_lowercase(); + let search_term_match = commands.iter().find(|sig| { + sig.search_terms + .iter() + .any(|term| term.to_lowercase() == command_name_lower) + }); + match search_term_match { + Some(sig) => Some(sig.name.clone()), + None => { + let command_names: Vec = commands.iter().map(|sig| sig.name.clone()).collect(); + did_you_mean(&command_names, attempted_command) + } + } +} + fn has_unsafe_shell_characters(arg: &str) -> bool { let re: Regex = Regex::new(r"[^\w@%+=:,./-]").expect("regex to be valid"); diff --git a/crates/nu-command/tests/commands/all.rs b/crates/nu-command/tests/commands/all.rs index 3882da785a..b597e3f4a4 100644 --- a/crates/nu-command/tests/commands/all.rs +++ b/crates/nu-command/tests/commands/all.rs @@ -65,5 +65,5 @@ fn checks_if_all_returns_error_with_invalid_command() { "# )); - assert!(actual.err.contains("can't run executable") || actual.err.contains("type_mismatch")); + assert!(actual.err.contains("can't run executable") || actual.err.contains("did you mean")); } diff --git a/crates/nu-command/tests/commands/any.rs b/crates/nu-command/tests/commands/any.rs index a3aad7cfa0..dea2f6cde1 100644 --- a/crates/nu-command/tests/commands/any.rs +++ b/crates/nu-command/tests/commands/any.rs @@ -41,5 +41,5 @@ fn checks_if_any_returns_error_with_invalid_command() { "# )); - assert!(actual.err.contains("can't run executable") || actual.err.contains("type_mismatch")); + assert!(actual.err.contains("can't run executable") || actual.err.contains("did you mean")); } diff --git a/tests/shell/pipeline/commands/external.rs b/tests/shell/pipeline/commands/external.rs index d48975585b..ee894f562f 100644 --- a/tests/shell/pipeline/commands/external.rs +++ b/tests/shell/pipeline/commands/external.rs @@ -107,6 +107,19 @@ fn passes_binary_data_between_externals() { ) } +#[test] +fn command_not_found_error_suggests_search_term() { + // 'distinct' is not a command, but it is a search term for 'uniq' + let actual = nu!(cwd: ".", "ls | distinct"); + assert!(actual.err.contains("uniq")); +} + +#[test] +fn command_not_found_error_suggests_typo_fix() { + let actual = nu!(cwd: ".", "benhcmark { echo 'foo'}"); + assert!(actual.err.contains("benchmark")); +} + mod it_evaluation { use super::nu; use nu_test_support::fs::Stub::{EmptyFile, FileWithContent, FileWithContentToBeTrimmed};