diff --git a/invoke/program.py b/invoke/program.py index c7e5cd004..104aae799 100644 --- a/invoke/program.py +++ b/invoke/program.py @@ -1,3 +1,4 @@ +import difflib import getpass import inspect import json @@ -86,6 +87,12 @@ def core_args(self) -> List["Argument"]: default=False, help="Echo executed commands before running.", ), + Argument( + names=("suggestions", "s"), + kind=bool, + default=True, + help="Show possible commands suggestions.", + ), Argument( names=("help", "h"), optional=True, @@ -403,6 +410,11 @@ def run(self, argv: Optional[List[str]] = None, exit: bool = True) -> None: # problems. if isinstance(e, ParseError): print(e, file=sys.stderr) + if "No idea what " in str(e) and self.args.suggestions.value: + unrecognised_cmd = str(e).replace("No idea what '", "") + unrecognised_cmd = unrecognised_cmd.replace("' is!", "") + msg = self._possible_commands_msg(unrecognised_cmd) + print(msg, file=sys.stderr) if isinstance(e, Exit) and e.message: print(e.message, file=sys.stderr) if isinstance(e, UnexpectedExit) and e.result.hide: @@ -985,3 +997,53 @@ def print_columns( else: print(spec.rstrip()) print("") + + def _possible_commands_msg(self, unknown_cmd: str) -> str: + all_tasks = {} + if hasattr(self, "scoped_collection"): + all_tasks = self.scoped_collection.task_names + possible_cmds = list(all_tasks.keys()) + suggestions = _get_best_match( + unknown_cmd, possible_cmds, n=3, cutoff=0.7 + ) + output_message = f"'{unknown_cmd}' is not an invoke command. " + output_message += "See 'invoke --list'.\n" + if suggestions: + output_message += "\nThe most similar command(s):\n" + for cmd in suggestions: + output_message += f" {cmd}\n" + else: + output_message += "\nNo suggestions was found.\n" + return output_message + + +def _get_best_match( + word: str, possibilities: List[str], n: int = 3, cutoff: float = 0.7 +) -> List[str]: + """Return a list of the top `n` best-matching commands for a given word. + + This function accounts for dot-separated commands by normalizing them— + splitting them into parts, sorting them alphabetically, and rejoining them. + This allows for matching commands that contain the same elements but in + different orders. + + For example, 'task1.task2' and 'task2.task1' will have a similarity score + of 0.98. + """ + normalized_unknown_cmd = ".".join(sorted(word.split("."))) + matches = [] + for cmd in possibilities: + normalized_cmd = ".".join(sorted(cmd.split("."))) + similarity_normalized = difflib.SequenceMatcher( + None, normalized_unknown_cmd, normalized_cmd + ).ratio() + similarity_raw = difflib.SequenceMatcher(None, word, cmd).ratio() + # The idea here is to decrease the similarity score if we have + # reordered the given word + similarity = max(similarity_normalized * 0.98, similarity_raw) + if similarity >= cutoff: + matches.append((similarity, cmd)) + + matches.sort(reverse=True) + + return [match[1] for match in matches[:n]] diff --git a/tests/program.py b/tests/program.py index 2d249b1fb..640120eb0 100644 --- a/tests/program.py +++ b/tests/program.py @@ -41,6 +41,13 @@ pytestmark = pytest.mark.usefixtures("integration") +no_idea_what_template = """ +No idea what '{0}' is! +'{0}' is not an invoke command. See 'invoke --list'. + +No suggestions was found. + +""".lstrip() class Program_: @@ -360,7 +367,7 @@ def seeks_and_loads_tasks_module_by_default(self): def does_not_seek_tasks_module_if_namespace_was_given(self): expect( "foo", - err="No idea what 'foo' is!\n", + err=no_idea_what_template.format("foo"), program=Program(namespace=Collection("blank")), ) @@ -369,6 +376,22 @@ def explicit_namespace_works_correctly(self): ns = Collection.from_module(load("integration")) expect("print-foo", out="foo\n", program=Program(namespace=ns)) + def correctly_suggest_most_simillart_command(self): + ns = Collection.from_module(load("integration")) + expected = """ +No idea what '{0}' is! +'{0}' is not an invoke command. See 'invoke --list'. + +The most similar command(s): + print-foo + +""".lstrip() + expect( + "print-fo", + err=expected.format("print-fo"), + program=Program(namespace=ns), + ) + def allows_explicit_task_module_specification(self): expect("-c integration print-foo", out="foo\n") @@ -402,7 +425,7 @@ def ParseErrors_display_message_and_exit_1(self, mock_exit): # "no idea what foo is!") and exit 1. (Intent is to display that # info w/o a full traceback, basically.) stderr = sys.stderr.getvalue() - assert stderr == "No idea what '{}' is!\n".format(nah) + assert stderr == no_idea_what_template.format(nah) mock_exit.assert_called_with(1) @trap @@ -599,6 +622,7 @@ def core_help_option_prints_core_help(self): -r STRING, --search-root=STRING Change root directory used for finding task modules. -R, --dry Echo commands instead of running. + -s, --[no-]suggestions Show possible commands suggestions. -T INT, --command-timeout=INT Specify a global command execution timeout, in seconds. -V, --version Show version and exit. @@ -736,7 +760,8 @@ def exits_after_printing(self): expect("-c decorators -h punch --list", out=expected) def complains_if_given_invalid_task_name(self): - expect("-h this", err="No idea what 'this' is!\n") + expected = no_idea_what_template.format("this") + expect("-h this", err=expected) class task_list: "--list"